diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5318fef1572..af8ad77634a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -465,6 +465,7 @@ jobs: echo "http://localhost" # submitty url echo "" # vcs url echo "" # institution name + echo "y" # user create account echo "" # sysadmin email echo "" # where to report echo "1" # PamAuth @@ -698,6 +699,17 @@ jobs: spec: cypress/e2e/Cypress-System/login.spec.js working-directory: ${{env.SUBMITTY_REPOSITORY}}/site browser: chrome + - name: Switch to CI mode + run: | + sudo sed -ie '/is_ci/c\ \"is_ci\" : true,' /usr/local/submitty/config/submitty.json + + - name: Run self-account-creation tests. + uses: cypress-io/github-action@v6 + with: + config: baseUrl=http://localhost + spec: cypress/e2e/Cypress-System/self_account_creation.spec.js + working-directory: ${{env.SUBMITTY_REPOSITORY}}/site + browser: chrome - name: Switch to LDAP auth run: sudo sed -ie "s/Database/Ldap/g" ${SUBMITTY_INSTALL_DIR}/config/authentication.json @@ -746,12 +758,18 @@ jobs: http_ver=$(curl -ksI https://localhost -o/dev/null -w "%{http_version}\n") [ "$http_ver" = "2" ] && echo "Pass" || echo "::warning::Failed" + - uses: actions/upload-artifact@v4 + if: failure() + with: + name: Submitty-JSON + path: ${{env.SUBMITTY_INSTALL_DIR}}/config/submitty.json - uses: actions/upload-artifact@v4 if: failure() with: name: cypress-screenshots path: ${{env.SUBMITTY_REPOSITORY}}/site/cypress/screenshots + - uses: actions/upload-artifact@v4 if: failure() with: diff --git a/.setup/CONFIGURE_SUBMITTY.py b/.setup/CONFIGURE_SUBMITTY.py index cb897a83384..9a215ce04a0 100644 --- a/.setup/CONFIGURE_SUBMITTY.py +++ b/.setup/CONFIGURE_SUBMITTY.py @@ -185,6 +185,7 @@ def __call__(self, parser, namespace, values, option_string=None): 'authentication_method': 0, 'institution_name' : '', 'institution_homepage' : '', + 'user_create_account' : False, 'timezone' : str(tzlocal.get_localzone()), 'submitty_admin_username': '', 'email_user': '', @@ -309,7 +310,16 @@ def __call__(self, parser, namespace, values, option_string=None): INSTITUTION_HOMEPAGE = '' print() - + while True: + user_create_account = get_input("Enable Create New Account feature? [y/n]", 'y') + if (user_create_account.lower() in ['yes', 'y']): + USER_CREATE_ACCOUNT = True + break + elif (user_create_account.lower() in ['no', 'n']): + USER_CREATE_ACCOUNT = False + break + print() + SYS_ADMIN_EMAIL = get_input("What is the email for system administration?", defaults['sys_admin_email']) SYS_ADMIN_URL = get_input("Where to report problems with Submitty (url for help link)?", defaults['sys_admin_url']) @@ -445,6 +455,7 @@ def __call__(self, parser, namespace, values, option_string=None): config['institution_name'] = INSTITUTION_NAME config['institution_homepage'] = INSTITUTION_HOMEPAGE + config['user_create_account'] = USER_CREATE_ACCOUNT config['debugging_enabled'] = DEBUGGING_ENABLED # site_log_path is a holdover name. This could more accurately be called the "log_path" @@ -616,7 +627,28 @@ def write(x=''): ############################################################################## # Write submitty json +user_id_requirements = { + "all": True, + "require_name": False, + "min_length": 6, + "max_length": 25, + "name_requirements": { + "given_first": False, + "given_name": 2, + "family_name": 4 + }, + "require_email": False, + "email_requirements": { + "whole_email": False, + "whole_prefix": False, + "prefix_count": 6 + } +} +accepted_emails = { + "gmail.com": True, + "rpi.edu": True +} config = submitty_config config['submitty_install_dir'] = SUBMITTY_INSTALL_DIR config['submitty_repository'] = SUBMITTY_REPOSITORY @@ -638,6 +670,10 @@ def write(x=''): config['timezone'] = TIMEZONE config['default_locale'] = DEFAULT_LOCALE config['duck_special_effects'] = False + config['user_create_account'] = USER_CREATE_ACCOUNT + config['accepted_emails'] = accepted_emails + config['user_id_requirements'] = user_id_requirements + config['is_ci'] = False config['worker'] = True if args.worker == 1 else False diff --git a/.setup/ansible/roles/submitty_install/defaults/main.yml b/.setup/ansible/roles/submitty_install/defaults/main.yml index 84768b0513d..cee9ffb82ec 100644 --- a/.setup/ansible/roles/submitty_install/defaults/main.yml +++ b/.setup/ansible/roles/submitty_install/defaults/main.yml @@ -11,6 +11,7 @@ submitty_install_language: en_US submitty_install_submitty_url: localhost submitty_install_vcs_url: git.localhost submitty_install_institution_name: Example University +submitty_install_self_account_creation: n submitty_install_sysadmin_email: sysadmin@localhost submitty_install_submitty_email: submitty@localhost submitty_install_institution_url: localhost diff --git a/.setup/ansible/roles/submitty_install/tasks/main.yml b/.setup/ansible/roles/submitty_install/tasks/main.yml index 3c9264aadee..f1c15ac2460 100644 --- a/.setup/ansible/roles/submitty_install/tasks/main.yml +++ b/.setup/ansible/roles/submitty_install/tasks/main.yml @@ -44,6 +44,7 @@ {{ 'https' if submitty_install_ssl_enabled else 'http' }}://{{ submitty_install_submitty_url }} {{ submitty_install_vcs_url }} {{ submitty_install_institution_name }} + {{ submitty_install_self_account_creation }} {{ submitty_install_sysadmin_email }} {{ submitty_install_submitty_email }} {{ submitty_install_institution_url }} diff --git a/.setup/install_system.sh b/.setup/install_system.sh index 0238f2b998d..0eab50d4b92 100644 --- a/.setup/install_system.sh +++ b/.setup/install_system.sh @@ -708,6 +708,7 @@ en_US ${SUBMISSION_URL} +y sysadmin@example.com https://example.com 1 diff --git a/.setup/testing/setup.sh b/.setup/testing/setup.sh index 661b91259b6..12f17822876 100644 --- a/.setup/testing/setup.sh +++ b/.setup/testing/setup.sh @@ -58,6 +58,7 @@ en_US http://localhost +y sysadmin@example.com https://example.com 1 diff --git a/autograder/tests/data/submitty_config.json b/autograder/tests/data/submitty_config.json index a13c8a0111c..bb22b2732ae 100644 --- a/autograder/tests/data/submitty_config.json +++ b/autograder/tests/data/submitty_config.json @@ -12,6 +12,7 @@ "cgi_url": "https://submitty.test.com/cgi-bin", "websocket_port": 8443, "institution_name": "Test University", + "user_create_account": false, "institution_homepage": "https://test.com", "timezone": "America/Los_Angeles", "duck_special_effects": false, diff --git a/migration/migrator/data/submitty_db.sql b/migration/migrator/data/submitty_db.sql index 978f09ddf66..1a7d9e44a89 100644 --- a/migration/migrator/data/submitty_db.sql +++ b/migration/migrator/data/submitty_db.sql @@ -646,6 +646,21 @@ CREATE TABLE public.terms ( ); +-- +-- Name: unverified_users; Type: TABLE; Schema: public; Owner: - +-- + +CREATE TABLE public.unverified_users ( + user_id character varying NOT NULL, + user_givenname character varying NOT NULL, + user_password character varying, + user_familyname character varying NOT NULL, + user_email character varying NOT NULL, + verification_code character varying(50) DEFAULT 'none'::character varying NOT NULL, + verification_expiration timestamp without time zone DEFAULT CURRENT_TIMESTAMP +); + + -- -- Name: users; Type: TABLE; Schema: public; Owner: - -- diff --git a/migration/migrator/migrations/master/20240805165505_user_verification_code.py b/migration/migrator/migrations/master/20240805165505_user_verification_code.py new file mode 100644 index 00000000000..04179e5f7f2 --- /dev/null +++ b/migration/migrator/migrations/master/20240805165505_user_verification_code.py @@ -0,0 +1,35 @@ +"""Migration for the Submitty master database.""" + + +def up(config, database): + """ + Run up migration. + + :param config: Object holding configuration details about Submitty + :type config: migrator.config.Config + :param database: Object for interacting with given database for environment + :type database: migrator.db.Database + """ + + database.execute(''' + CREATE TABLE if not exists public.unverified_users ( + user_id character varying NOT NULL, + user_givenname character varying NOT NULL, + user_password character varying, + user_familyname character varying NOT NULL, + user_email character varying NOT NULL, + verification_code character varying(50) NOT NULL DEFAULT 'none', + verification_expiration timestamp DEFAULT current_timestamp + ) + ''') + +def down(config, database): + """ + Run down migration (rollback). + + :param config: Object holding configuration details about Submitty + :type config: migrator.config.Config + :param database: Object for interacting with given database for environment + :type database: migrator.db.Database + """ + pass diff --git a/migration/migrator/migrations/system/20240814131600_self_account_creation.py b/migration/migrator/migrations/system/20240814131600_self_account_creation.py new file mode 100644 index 00000000000..ba82c3730e3 --- /dev/null +++ b/migration/migrator/migrations/system/20240814131600_self_account_creation.py @@ -0,0 +1,51 @@ +"""Migration for the Submitty system.""" +import json +user_id_requirements = { + "all": True, + "require_name": False, + "min_length": 6, + "max_length": 25, + "name_requirements": { + "given_first": False, + "given_name": 2, + "family_name": 4 + }, + "require_email": False, + "email_requirements": { + "whole_email": False, + "whole_prefix": False, + "prefix_count": 6 + } +} + +accepted_emails = { + "gmail.com": True, + "rpi.edu": True +} + +def up(config): + """ + Run up migration. + + :param config: Object holding configuration details about Submitty + :type config: migrator.config.Config + """ + edited_config = config.submitty + if ('user_create_account' not in edited_config): + edited_config['user_create_account'] = False + if ('user_id_requirements' not in edited_config): + edited_config['user_id_requirements'] = user_id_requirements + if ('accepted_emails' not in edited_config): + edited_config['accepted_emails'] = accepted_emails + + with open(config.config_path / 'submitty.json', 'w') as file_path: + json.dump(edited_config, file_path, indent=4) + +def down(config): + """ + Run down migration (rollback). + + :param config: Object holding configuration details about Submitty + :type config: migrator.config.Config + """ + pass diff --git a/migration/migrator/migrations/system/20240828152944_ci_mode.py b/migration/migrator/migrations/system/20240828152944_ci_mode.py new file mode 100644 index 00000000000..c9045f8bd1e --- /dev/null +++ b/migration/migrator/migrations/system/20240828152944_ci_mode.py @@ -0,0 +1,27 @@ +"""Migration for the Submitty system.""" +import json + +def up(config): + """ + Run up migration. + + :param config: Object holding configuration details about Submitty + :type config: migrator.config.Config + """ + edited_config = config.submitty + if ('is_ci' not in edited_config): + edited_config['is_ci'] = False + + with open(config.config_path / 'submitty.json', 'w') as file_path: + json.dump(edited_config, file_path, indent=4) + + + +def down(config): + """ + Run down migration (rollback). + + :param config: Object holding configuration details about Submitty + :type config: migrator.config.Config + """ + pass diff --git a/site/app/controllers/AuthenticationController.php b/site/app/controllers/AuthenticationController.php index e101f7112c6..c9634c4c558 100644 --- a/site/app/controllers/AuthenticationController.php +++ b/site/app/controllers/AuthenticationController.php @@ -13,6 +13,8 @@ use app\libraries\Logger; use app\libraries\response\MultiResponse; use app\views\AuthenticationView; +use app\models\User; +use app\models\Email; use app\repositories\VcsAuthTokenRepository; use Symfony\Component\Routing\Annotation\Route; @@ -308,4 +310,274 @@ public function userSelection() { return new WebResponse(AuthenticationView::class, 'userSelection', $users); } + + /** + * Check if password has at least one of the following, Upper case letter, Lower case letter, Special character, and number + */ + public function checkChars(string $password): bool { + $upperCase = preg_match('/[A-Z]/', $password); + $lowerCase = preg_match('/[a-z]/', $password); + $specialChar = preg_match('/[^A-Za-z0-9]/', $password); + $numericVal = preg_match('/[0-9]/', $password); + return $upperCase >= 1 && $lowerCase >= 1 && $specialChar >= 1 && $numericVal >= 1; + } + + /** + * Check if the user ID meets requirements + */ + public function isAcceptedUserId(string $user_id, string $given_name, string $family_name, string $email): bool { + $requirements = $this->core->getConfig()->getUserIdRequirements(); + + if ($requirements['max_length'] < strlen($user_id) || $requirements['min_length'] > strlen($user_id)) { + return false; + } + + if ($requirements['all'] === true) { + return true; + } + elseif ($requirements['require_name'] === true) { + $name_requirements = $requirements['name_requirements']; + $given_first = $name_requirements['given_first'] === 'true'; + + $id_given_name = substr($user_id, ($given_first ? 0 : $name_requirements['family_name']), ($given_first ? $name_requirements['given_name'] : strlen($user_id))); + $id_family_name = substr($user_id, ($given_first ? $name_requirements['given_name'] : 0), ($given_first ? strlen($user_id) : $name_requirements['family_name'])); + $is_given_name = (strtolower($id_given_name) === substr(strtolower($given_name), 0, $name_requirements['given_name'])); + $is_family_name = (strtolower($id_family_name) === substr(strtolower($family_name), 0, $name_requirements['family_name'])); + if ($is_family_name && $is_given_name) { + return true; + } + return false; + } + elseif ($requirements['require_email'] === true) { + if ($requirements['email_requirements']['whole_email']) { + return $user_id === $email; + } + elseif ($requirements['email_requirements']['whole_prefix']) { + $split_email = explode('@', $email); + $email_extension = array_pop($split_email); + return $user_id === implode('', $split_email); + } + else { + return substr($user_id, 0, $requirements['email_requirements']['prefix_count']) === substr($email, 0, $requirements['email_requirements']['prefix_count']); + } + } + else { + return false; + } + } + + /** + * Returns true if the password is greater than or equal to 12 characters, and has the required characters + */ + public function isGoodPassword(string $password): bool { + return strlen($password) >= 12 && $this->checkChars($password); + } + + /** + * Checks if the email extension is in the accepted emails part of the Submitty config file + */ + public function isAcceptedEmail(string $email): bool { + $emails = $this->core->getConfig()->getAcceptedEmails(); + // Check if the file was read successfully + try { + $split_email = explode('@', $email); + $email_extension = $split_email[count($split_email) - 1]; + } + catch (\Error $error) { + return false; + } + return in_array($email_extension, array_keys($emails), true); + } + + /** + * @return array + */ + public function generateVerificationCode(): array { + $code = $this->core->getConfig()->isCi() ? '00000000' : Utils::generateRandomString(); + $timestamp = time() + 60 * 15; // 15 minutes from now, may eventually set this as a configurable value. + return ['code' => strval($code), 'exp' => $timestamp]; + } + + public function sendVerificationEmail(string $email, string $verification_code, string $user_id): void { + $subject = "Submitty Email Verification"; + $url = $this->core->getConfig()->getBaseUrl() . 'authentication/verify_email?verification_code=' . $verification_code; + $body = << $subject, "body" => $body, "email_address" => $email, 'to_name' => $user_id]; + $email = new Email($this->core, $details); + $emails = [$email]; + $this->core->getNotificationFactory()->sendEmails($emails); + } + + /** + * Display the form for creating a new account + */ + #[Route("/authentication/create_account", methods: ['GET'])] + public function signupForm(): ResponseInterface { + // Check if the user is already logged in, if yes, redirect to home or another appropriate page + if ($this->logged_in) { + return new RedirectResponse($this->core->buildUrl(['home'])); + } + if (!$this->core->getConfig()->isUserCreateAccount()) { + $this->core->addErrorMessage('Users cannot create their own account, Please have your system administrator add you.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + return new WebResponse('Authentication', 'signupForm', ['email' => $this->core->getConfig()->getAcceptedEmails(), 'user_id' => $this->core->getConfig()->getUserIdRequirements()]); + } + + /** + * Display the form for creating a new account + */ + #[Route("/authentication/email_verification")] + public function showVerifyEmailForm(): ResponseInterface { + // Check if the user is already logged in, if yes, redirect to home or another appropriate page + if (!$this->core->getConfig()->isUserCreateAccount()) { + $this->core->addErrorMessage('Users cannot create their own account, Please have your system administrator add you.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + return new WebResponse('Authentication', 'verificationForm'); + } + + /** + * Display the form for creating a new account + */ + #[Route("/authentication/resend_email")] + public function resendVerificationEmail(): ResponseInterface { + // Check if the user is already logged in, if yes, redirect to home or another appropriate page + if ($this->logged_in) { + return new RedirectResponse($this->core->buildUrl(['home'])); + } + if (!$this->core->getConfig()->isUserCreateAccount()) { + $this->core->addErrorMessage('Users cannot create their own account, Please have your system administrator add you.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + if (!isset($_GET['email'])) { + $this->core->addErrorMessage('You must specify an email to send the verification to.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'email_verification'])); + } + $unverified_users = $this->core->getQueries()->getUnverifiedUserIdEmailExists($_GET['email'], ''); + if (count($unverified_users) === 0) { + $this->core->addErrorMessage('Either you have already verified your email, or that email is not associated with an account.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + $verification_values = $this->generateVerificationCode(); + $this->core->getQueries()->updateUserVerificationValues($_GET['email'], $verification_values['code'], $verification_values['exp']); + $this->sendVerificationEmail($_GET['email'], $verification_values['code'], $unverified_users['user_id']); + $this->core->addSuccessMessage('Verification email resent.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'email_verification'])); + } + + #[Route("/authentication/verify_email")] + public function verifyEmail(): RedirectResponse { + // Check if the user is already logged in, if yes, redirect to home or another appropriate page + if (!$this->core->getConfig()->isUserCreateAccount()) { + $this->core->addErrorMessage('Users cannot create their own account, Please have your system administrator add you.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + + $verification_values = $this->core->getQueries()->getUserVerificationValuesByCode($_GET['verification_code']); + + if ($verification_values === []) { + $this->core->addErrorMessage('The verification code is not correct, please resend email verification.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'email_verification'])); + } + + $this->core->addSuccessMessage('You have successfully verified your email.'); + $user = $this->core->getQueries()->getUnverifiedUserByCode($_GET['verification_code']); + $this->core->getQueries()->insertSubmittyUser($user); + $this->core->getQueries()->removeUnverifiedUserByCode($_GET['verification_code']); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + + /** + * Handles the submission of the new account creation form + */ + #[Route("/authentication/self_add_user")] + public function addNewUser(): RedirectResponse { + // Check if the user is already logged in, if yes, redirect to home or another appropriate page + if ($this->logged_in) { + return new RedirectResponse($this->core->buildUrl(['home'])); + } + + // Should never happen, however they can visit this URL manually, so this is to prevent unwanted account creation. + if (!$this->core->getConfig()->isUserCreateAccount()) { + $this->core->addErrorMessage('Users cannot create their own account, Please have your system administrator add you.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'login'])); + } + + $user_id = $_POST['user_id']; + $email = $_POST['email']; + $password = $_POST['password']; + $confirm_password = $_POST['confirm_password']; + + $verified_users = $this->core->getQueries()->getUserIdEmailExists($email, $user_id); + $unverified_users = $this->core->getQueries()->getUnverifiedUserIdEmailExists($email, $user_id); + + if (in_array($email, array_column($unverified_users, 'user_email'), true) || in_array($email, array_column($verified_users, 'user_email'), true)) { + $this->core->addErrorMessage('Email already exists.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'email_verification'])); + } + + if (in_array($user_id, array_column($verified_users, 'user_id'), true)) { + $this->core->addErrorMessage('User ID already exists'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'create_account'])); + } + + if (!$this->isGoodPassword($password)) { + $this->core->addErrorMessage('Password does not meet the requirements.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'create_account'])); + } + + if ($password !== $confirm_password) { + $this->core->addErrorMessage('Passwords did not match.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'create_account'])); + } + + if (!$this->isAcceptedEmail($email)) { + $this->core->addErrorMessage('This email is not accepted.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'create_account'])); + } + + if (!$this->isAcceptedUserId($user_id, $_POST['given_name'], $_POST['family_name'], $email)) { + $this->core->addErrorMessage('This user id does not meet requirements.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'create_account'])); + } + $verification_values = $this->generateVerificationCode(); + $user = new User($this->core, [ + 'user_id' => $user_id, + 'user_givenname' => $_POST['given_name'], + 'user_familyname' => $_POST['family_name'], + 'user_password' => $password, + 'user_pronouns' => '', + 'display_pronouns' => false, + 'user_email' => $email, + 'user_email_secondary' => '', + 'user_email_secondary_notify' => false, + 'user_verification_code' => $verification_values['code'], + 'user_verification_expiration' => $verification_values['exp'] + ]); + + try { + $this->core->getQueries()->insertUnverifiedSubmittyUser($user); + $this->sendVerificationEmail($email, $verification_values['code'], $user_id); + $this->core->addSuccessMessage('Verification Email Sent'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'email_verification'])); + } + catch (\Error $e) { + Logger::error($e); + $this->core->addErrorMessage('Failed to create the account.'); + return new RedirectResponse($this->core->buildUrl(['authentication', 'create_account'])); + } + } } diff --git a/site/app/libraries/database/DatabaseQueries.php b/site/app/libraries/database/DatabaseQueries.php index 094bc0ff63f..d57b6048ff2 100644 --- a/site/app/libraries/database/DatabaseQueries.php +++ b/site/app/libraries/database/DatabaseQueries.php @@ -157,6 +157,24 @@ public function getUserById(string $user_id): ?User { return $this->getUser($user_id); } + /** + * Gets an unverified user from the database given a verification code. + * + * @return User|null + */ + public function getUnverifiedUserByCode(string $code): ?User { + $this->submitty_db->query("SELECT * FROM unverified_users WHERE verification_code=?", [$code]); + return new User($this->core, $this->submitty_db->row()); + } + + /** + * Removes an unverified user from the database given a verification code. + * + */ + public function removeUnverifiedUserByCode(string $code): void { + $this->submitty_db->query("DELETE FROM unverified_users WHERE verification_code=?", [$code]); + } + /** * Gets a user from the database given a numeric user_id. * @@ -535,7 +553,6 @@ public function insertSubmittyUser(User $user) { $user->getLegalFamilyName(), $user->getPreferredFamilyName(), $user->getEmail(), $this->submitty_db->convertBoolean($user->isUserUpdated()), $this->submitty_db->convertBoolean($user->isInstructorUpdated())]; - $this->submitty_db->query( "INSERT INTO users (user_id, user_password, user_numeric_id, user_givenname, user_preferred_givenname, user_familyname, user_preferred_familyname, user_email, user_updated, instructor_updated) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", @@ -543,6 +560,33 @@ public function insertSubmittyUser(User $user) { ); } + /** + * @param User $user + */ + public function insertUnverifiedSubmittyUser(User $user): void { + $array = [ + $user->getId(), + $user->getPassword(), + $user->getLegalGivenName(), + $user->getLegalFamilyName(), + $user->getEmail(), + $user->getVerificationExpiration(), + $user->getVerificationCode() + ]; + $this->submitty_db->query( + "INSERT INTO unverified_users ( + user_id, + user_password, + user_givenname, + user_familyname, + user_email, + verification_expiration, + verification_code + ) VALUES (?, ?, ?, ?, ?, to_timestamp(?), ?)", + $array + ); + } + /** * Helper function for generating sql query according to the given requirements */ @@ -7840,6 +7884,49 @@ public function getEmailListWithIds() { return $this->course_db->rows(); } + /** + * Gets a list of emails with user ids for all active particpants in Submitty + * array + * @return array + */ + public function getUserIdEmailExists(string $email, string $user_id): array { + $parameters = [$email, $user_id]; + $this->submitty_db->query('SELECT user_id, user_email FROM users where user_email=? or user_id=?', $parameters); + return $this->submitty_db->rows(); + } + + /** + * Gets a list of emails with user ids for all active particpants in Submitty + * @return array + */ + public function getUnverifiedUserIdEmailExists(string $email, string $user_id): array { + $parameters = [$email, $user_id]; + $this->submitty_db->query('SELECT user_id, user_email FROM unverified_users where user_email=? or user_id=?', $parameters); + return $this->submitty_db->rows(); + } + + /** + * Gets verification values given a verification code + * @return array + */ + public function getUserVerificationValuesByCode(string $code): array { + $parameters = [$code]; + $this->submitty_db->query('SELECT verification_code, verification_expiration FROM unverified_users where verification_code=?', $parameters); + $values = $this->submitty_db->row(); + if ($values === [] || $values['verification_expiration'] < time()) { + return []; + } + return $values; + } + + /** + * Updates a users verification values given an email + */ + public function updateUserVerificationValues(string $email, string $code, int $timestamp): void { + $parameters = [$code, $timestamp, $email]; + $this->submitty_db->query('UPDATE unverified_users SET verification_code=?, verification_expiration=to_timestamp(?) where user_email=?', $parameters); + } + /** * Gives true if thread is locked */ diff --git a/site/app/models/Config.php b/site/app/models/Config.php index 43bee9bf307..18e714bb3ca 100644 --- a/site/app/models/Config.php +++ b/site/app/models/Config.php @@ -55,6 +55,7 @@ * @method string getSysAdminEmail() * @method string getSysAdminUrl() * @method string getCourseEmail() + * @method bool isUserCreateAccount() * @method string getVcsUser() * @method string getVcsType() * @method string getPrivateRepository() @@ -62,6 +63,8 @@ * @method void setRoomSeatingGradeableId(string $gradeable_id) * @method bool isSeatingOnlyForInstructor() * @method array getCourseJson() + * @method array getAcceptedEmails() + * @method array getUserIdRequirements() * @method string getSecretSession() * @method string getAutoRainbowGrades() * @method string|null getVerifiedSubmittyAdminUser() @@ -79,6 +82,7 @@ * @method string getSubmittyInstallPath() * @method bool isDuckBannerEnabled() * @method string getPhpUser() + * @method bool isCi() */ class Config extends AbstractModel { @@ -91,6 +95,11 @@ class Config extends AbstractModel { */ protected $debug = false; + /** + * @prop + * @var bool Is this being run on CI? (Allows for mocking of certain 'random' functions).*/ + protected $ci = false; + /** @prop * @var string contains the term to use, generally from the $_REQUEST['semester'] global */ protected $term; @@ -109,6 +118,14 @@ class Config extends AbstractModel { * @var array */ protected $course_json = []; + /** @prop + * @var array */ + protected $user_id_requirements = []; + + /** @prop + * @var array */ + protected $accepted_emails = []; + /** * Indicates whether a course config has been successfully loaded. * @var bool @@ -292,6 +309,9 @@ class Config extends AbstractModel { /** @prop * @var bool */ protected $seating_only_for_instructor; + /** @prop + * @var bool */ + protected $user_create_account; /** @prop * @var string|null */ protected $room_seating_gradeable_id; @@ -421,6 +441,11 @@ public function loadMasterConfigs($config_path) { $this->sys_admin_email = $submitty_json['sys_admin_email'] ?? ''; $this->sys_admin_url = $submitty_json['sys_admin_url'] ?? ''; + $this->user_create_account = $submitty_json['user_create_account'] === true; + $this->ci = ($submitty_json['is_ci'] ?? false) === true; + $this->user_id_requirements = $submitty_json['user_id_requirements']; + $this->accepted_emails = $submitty_json['accepted_emails']; + if (isset($submitty_json['timezone'])) { if (!in_array($submitty_json['timezone'], \DateTimeZone::listIdentifiers())) { throw new ConfigException("Invalid Timezone identifier: {$submitty_json['timezone']}"); diff --git a/site/app/models/User.php b/site/app/models/User.php index a6050a7630d..90d286ed19a 100644 --- a/site/app/models/User.php +++ b/site/app/models/User.php @@ -31,6 +31,8 @@ * @method int getLastInitialFormat() * @method string getDisplayNameOrder() * @method void setDisplayNameOrder() + * @method string getVerificationCode() + * @method int getVerificationExpiration() * @method string getEmail() * @method void setEmail(string $email) * @method string getSecondaryEmail() @@ -139,6 +141,12 @@ class User extends AbstractModel { /** @prop * @var string The secondary email of the user */ protected $secondary_email; + /** @prop + * @var string Email verification code */ + protected $verification_code; + /** @prop + * @var int Timestamp of the expiration of the verification code */ + protected $verification_expiration; /** @prop * @var string Determines whether or not user chose to receive emails to secondary email */ protected $email_both; @@ -290,6 +298,11 @@ public function __construct(Core $core, $details = []) { $this->time_zone = $details['time_zone'] ?? 'NOT_SET/NOT_SET'; + if (isset($details['user_verification_code'])) { + $this->core->getQueries()->updateUserVerificationValues($details['user_email'], $details['user_verification_code'], $details['user_verification_expiration']); + $this->verification_expiration = $details['user_verification_expiration']; + $this->verification_code = $details['user_verification_code']; + } if (isset($details['user_preferred_locale'])) { $this->preferred_locale = $details['user_preferred_locale']; $this->core->getConfig()->setLocale($this->preferred_locale); diff --git a/site/app/templates/Authentication.twig b/site/app/templates/Authentication.twig index bf8c2cd6b90..b58fc9b6a3d 100644 --- a/site/app/templates/Authentication.twig +++ b/site/app/templates/Authentication.twig @@ -24,6 +24,9 @@ + {% if user_create_account and is_database_auth %} + {{ new_account_text }} + {% endif %} {% endif %} diff --git a/site/app/templates/CreateNewAccount.twig b/site/app/templates/CreateNewAccount.twig new file mode 100644 index 00000000000..21240b02518 --- /dev/null +++ b/site/app/templates/CreateNewAccount.twig @@ -0,0 +1,88 @@ +{% set user_id_reqs = requirements["user_id"] %} +{% set name_reqs = requirements["user_id"]["name_requirements"] %} +{% set email_reqs = requirements["user_id"]["email_requirements"] %} +
+
+ +

Sign Up + +

+
+ +
+

Email

+
+ Accepted Email Extensions:
+ {% for email, bool in requirements['email'] %} + @{{ email }}
+ {% endfor %} +
+ +
+
+

User ID

+
+ UserID Requirements:
+ {% if user_id_reqs["all"] == true %} + * Must be greater than {{ user_id_reqs["min_length"] }} + characters, and less than + {{ user_id_reqs["max_length"] }} + characters in length + {% elseif user_id_reqs['require_name'] == true %} + * Must be + {{ name_reqs['given_first'] ? + (name_reqs['given_name'] ~ ' characters of your given name followed by ') : + (name_reqs['family_name'] ~ ' characters of your family name followed by' )}} + {{ name_reqs['given_first'] ? + (name_reqs['family_name'] ~ ' characters of your family name') : + (name_reqs['given_name'] ~ ' characters of your given name' )}} + {% elseif user_id_reqs['require_email'] == true %} + {% if user_id_reqs['whole_email'] %} + + {% endif %} + {% endif %} +
+ +
+
+ Given Name + +
+ +
+

Password

+
+ Password Requires at least:
+ 1 Capital Letter
+ 1 Lowercase Letter
+ 1 Number
+ 1 Special Character
+ 12 Characters
+
+ +
+ + +
+
+ + + + diff --git a/site/app/templates/VerifyEmailForm.twig b/site/app/templates/VerifyEmailForm.twig new file mode 100644 index 00000000000..6549c83699f --- /dev/null +++ b/site/app/templates/VerifyEmailForm.twig @@ -0,0 +1,24 @@ +
+
+ +

Verify your Email + +

+
+ + +
+ Enter Verification Code + + +
+
+
+ Resend Verification Code + + +
+ + +
+ diff --git a/site/app/views/AuthenticationView.php b/site/app/views/AuthenticationView.php index 881cdd16eb2..557d299208c 100644 --- a/site/app/views/AuthenticationView.php +++ b/site/app/views/AuthenticationView.php @@ -2,6 +2,7 @@ namespace app\views; +use app\authentication\DatabaseAuthentication; use app\libraries\Access; use app\libraries\FileUtils; @@ -21,12 +22,22 @@ public function loginForm($old = null, $isSaml = false) { $login_content = file_get_contents($path); } + $new_account_text = "New to Submitty? Sign up here."; + $path = FileUtils::joinPaths($this->core->getConfig()->getConfigPath(), "new_account.md"); + if (file_exists($path) && is_readable($path)) { + $new_account_text = file_get_contents($path); + } + return $this->core->getOutput()->renderTwigTemplate("Authentication.twig", [ "login_url" => $this->core->buildUrl(['authentication', 'check_login']) . '?' . http_build_query(['old' => $old]), "is_saml" => $isSaml, "saml_url" => $this->core->buildUrl(['authentication', 'saml_start']) . '?' . http_build_query(['old' => $old]), "saml_name" => $this->core->getConfig()->getSamlOptions()['name'], - "login_content" => $login_content + "login_content" => $login_content, + "user_create_account" => $this->core->getConfig()->isUserCreateAccount(), + "is_database_auth" => $this->core->getAuthentication() instanceof DatabaseAuthentication, + "new_account_url" => $this->core->buildUrl(['authentication', 'create_account']), + "new_account_text" => "New to Submitty? Sign up now!" ]); } @@ -38,4 +49,36 @@ public function userSelection(array $users) { "login_url" => $this->core->buildUrl(['authentication', 'check_login']) ]); } + + /** + * @param array $content + */ + public function signupForm(array $content): string { + $this->core->getOutput()->addInternalCss("input.css"); + $this->core->getOutput()->addInternalCss("links.css"); + $this->core->getOutput()->addInternalCss("authentication.css"); + $this->core->getOutput()->enableMobileViewport(); + + $signup_content = "# Sign Up"; + $path = FileUtils::joinPaths($this->core->getConfig()->getConfigPath(), "signup.md"); + if (file_exists($path) && is_readable($path)) { + $signup_content = file_get_contents($path); + } + return $this->core->getOutput()->renderTwigTemplate("CreateNewAccount.twig", [ + "signup_url" => $this->core->buildUrl(['authentication', 'self_add_user']), + "signup_content" => $signup_content, + "requirements" => $content + ]); + } + + public function verificationForm(): string { + $this->core->getOutput()->addInternalCss("input.css"); + $this->core->getOutput()->addInternalCss("links.css"); + $this->core->getOutput()->addInternalCss("authentication.css"); + $this->core->getOutput()->enableMobileViewport(); + return $this->core->getOutput()->renderTwigTemplate("VerifyEmailForm.twig", [ + 'verify_email_url' => $this->core->buildUrl(['authentication', 'verify_email']), + 'resend_email_url' => $this->core->buildUrl(['authentication', 'resend_email']), + ]); + } } diff --git a/site/cypress/e2e/Cypress-System/self_account_creation.spec.js b/site/cypress/e2e/Cypress-System/self_account_creation.spec.js new file mode 100644 index 00000000000..716087fdf33 --- /dev/null +++ b/site/cypress/e2e/Cypress-System/self_account_creation.spec.js @@ -0,0 +1,18 @@ +describe('Self account creation tests', () => { + it('Basic account creation test', () => { + cy.visit(); + cy.get('[data-testid="new-account-button"]').click(); + cy.get('[data-testid="email"]').type('test.email@gmail.com'); + cy.get('[data-testid="user-id"]').type('test_id'); + cy.get('[data-testid="given-name"]').type('GivenName'); + cy.get('[data-testid="family-name"]').type('FamilyName'); + cy.get('[data-testid="password"]').type('Password123!'); + cy.get('[data-testid="confirm-password"]').type('Password123!'); + cy.get('[data-testid="sign-up-button"]').click(); + cy.get('[data-testid="verification-code"').type('00000000'); + cy.get('[data-testid="verify-email-button"').click(); + cy.get('[data-testid="popup-message"]').should('contain', 'You have successfully verified your email.'); + cy.login('test_id', 'Password123!'); + cy.get('body').should('contain', 'My Courses'); + }); +}); diff --git a/site/cypress/support/commands.js b/site/cypress/support/commands.js index dce526feb48..f0af93b6c2e 100644 --- a/site/cypress/support/commands.js +++ b/site/cypress/support/commands.js @@ -33,7 +33,7 @@ import { buildUrl } from './utils.js'; * * @param {String} [username=instructor] - username & password of who to log in as */ -Cypress.Commands.add('login', (username = 'instructor') => { +Cypress.Commands.add('login', (username = 'instructor', password = username) => { cy.url({ decode: true }).then(($url) => { cy.request({ method: 'POST', @@ -42,7 +42,7 @@ Cypress.Commands.add('login', (username = 'instructor') => { followRedirect: false, body: { user_id: username, - password: username, + password: password, __csrf: username, }, }).then((response) => { diff --git a/site/public/css/authentication.css b/site/public/css/authentication.css index 69762b92e51..d66486c36c3 100644 --- a/site/public/css/authentication.css +++ b/site/public/css/authentication.css @@ -50,3 +50,45 @@ #saml-login { color: var(--btn-text-white); } + +.hidden-helper { + display: none; + padding: 10px; + font-size: small; +} + +.display-box input { + display: block; + width: 100%; +} + +.requirements-button { + display: inline-block; + font-weight: 400; + color: #212529; + text-align: center; + vertical-align: middle; + background-color: transparent; + border: 1px solid; + padding: 0.3rem; + font-size: x-small; + line-height: 1; + border-radius: 0.25rem; + transition: color 0.15s ease-in-out, background-color 0.15s ease-in-out, border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out; +} + + +@media (prefers-color-scheme: dark) { + .hidden-helper { + display: none; + position: absolute; + background-color: gray; + left: 42%; + height: 150px; + width: 225px; + padding-left: 10px; + padding-top: 10px; + border-radius: 20px; + } +} + diff --git a/site/tests/app/controllers/admin/ConfigurationControllerTester.php b/site/tests/app/controllers/admin/ConfigurationControllerTester.php index c7d63a2e878..463928ce738 100644 --- a/site/tests/app/controllers/admin/ConfigurationControllerTester.php +++ b/site/tests/app/controllers/admin/ConfigurationControllerTester.php @@ -32,7 +32,7 @@ public function setUpConfig($seating_dirs = []): void { 'email' => '{"email_enabled":true,"email_user":"","email_password":"","email_sender":"submitty@vagrant","email_reply_to":"do-not-reply@vagrant","email_server_hostname":"localhost","email_server_port":25}', 'secrets_submitty_php' => '{"session":"cGRZSDnVxdDjQwGyiq4ECnJyiZ8IQXEL1guSsJ1XlSKSEqisqvdCPhCRcYDEjpjm"}', 'submitty_admin' => '{"submitty_admin_username":"submitty-admin","token":"token"}', - 'submitty' => '{"submitty_install_dir":' . json_encode($this->test_dir) . ',"submitty_repository":' . json_encode($this->test_dir) . ',"submitty_data_dir":' . json_encode($this->test_dir) . ',"autograding_log_path":' . json_encode($this->test_dir) . ',"site_log_path":' . json_encode($this->test_dir) . ',"submission_url":"http:\/\/localhost:1501","vcs_url":"","cgi_url":"http:\/\/localhost:1501\/cgi-bin","institution_name":"","username_change_text":"foo","institution_homepage":"" ,"sys_admin_email": "admin@example.com","sys_admin_url": "https:\/\/example.com\/admin","timezone":"America\/New_York","worker":false,"duck_special_effects" : false}', + 'submitty' => '{"submitty_install_dir":' . json_encode($this->test_dir) . ',"submitty_repository":' . json_encode($this->test_dir) . ',"submitty_data_dir":' . json_encode($this->test_dir) . ',"autograding_log_path":' . json_encode($this->test_dir) . ',"site_log_path":' . json_encode($this->test_dir) . ',"submission_url":"http:\/\/localhost:1501","vcs_url":"","cgi_url":"http:\/\/localhost:1501\/cgi-bin","institution_name":"","username_change_text":"foo","institution_homepage":"" ,"sys_admin_email": "admin@example.com","sys_admin_url": "https:\/\/example.com\/admin","timezone":"America\/New_York","worker":false,"duck_special_effects":false,"user_create_account":false,"user_id_requirements":{"all":true,"require_name":false,"min_length":6,"max_length":25,"name_requirements":{"given_first":false,"given_name": 2,"family_name": 4},"require_email": false,"email_requirements": {"whole_email": false,"whole_prefix": false,"prefix_count": 6}},"accepted_emails":{"gmail.com": true,"rpi.edu": true}, "is_ci": false}', 'submitty_users' => '{"num_grading_scheduler_workers":5,"num_untrusted":60,"first_untrusted_uid":900,"first_untrusted_gid":900,"daemon_uid":1003,"daemon_gid":1006,"daemon_user":"submitty_daemon","course_builders_group":"submitty_course_builders","php_uid":1001,"php_gid":1004,"php_user":"submitty_php","cgi_user":"submitty_cgi","daemonphp_group":"submitty_daemonphp","daemoncgi_group":"submitty_daemoncgi","verified_submitty_admin_user":"submitty-admin"}', 'version' => '{"installed_commit":"7da8417edd6ff46f1d56e1a938b37c054a7dd071","short_installed_commit":"7da8417ed","most_recent_git_tag":"v19.09.04"}' ]; diff --git a/site/tests/app/models/ConfigTester.php b/site/tests/app/models/ConfigTester.php index 69d5103612b..bff4229fd33 100644 --- a/site/tests/app/models/ConfigTester.php +++ b/site/tests/app/models/ConfigTester.php @@ -94,8 +94,31 @@ private function createConfigFile($extra = []) { "course_code_requirements" => "Please follow your school's convention for course code.", "institution_homepage" => "https://rpi.edu", 'system_message' => "Some system message", + 'user_create_account' => false, "duck_special_effects" => false, "default_locale" => "default", + "user_id_requirements" => [ + "all" => true, + "require_name" => false, + "min_length" => 6, + "max_length" => 25, + "name_requirements" => [ + "given_first" => false, + "given_name" => 2, + "family_name" => 4 + ], + "require_email" => false, + "email_requirements" => [ + "whole_email" => false, + "whole_prefix" => false, + "prefix_count" => 6 + ] + ], + "accepted_emails" => [ + "gmail.com" => true, + "rpi.edu" => true + ], + "is_ci" => false ]; $config = array_replace($config, $extra); FileUtils::writeJsonFile(FileUtils::joinPaths($this->config_path, "submitty.json"), $config); @@ -330,6 +353,28 @@ public function testConfig() { 'vcs_url' => 'http://example.com/{$vcs_type}/', 'wrapper_files' => [], 'system_message' => 'Some system message', + 'user_create_account' => false, + "user_id_requirements" => [ + "all" => true, + "require_name" => false, + "min_length" => 6, + "max_length" => 25, + "name_requirements" => [ + "given_first" => false, + "given_name" => 2, + "family_name" => 4 + ], + "require_email" => false, + "email_requirements" => [ + "whole_email" => false, + "whole_prefix" => false, + "prefix_count" => 6 + ] + ], + "accepted_emails" => [ + "gmail.com" => true, + "rpi.edu" => true + ], 'secret_session' => 'LIW0RT5XAxOn2xjVY6rrLTcb6iacl4IDNRyPw58M0Kn0haQbHtNvPfK18xpvpD93', 'email_enabled' => true, 'auto_rainbow_grades' => false, @@ -348,6 +393,7 @@ public function testConfig() { 'date_time_format' => ['modified' => false], "default_locale" => "default", "locale" => ['modified' => false], + "ci" => false, ]; $actual = $config->toArray(); diff --git a/site/tests/app/models/UserTester.php b/site/tests/app/models/UserTester.php index 4b79476c5f8..d8b45d3d254 100644 --- a/site/tests/app/models/UserTester.php +++ b/site/tests/app/models/UserTester.php @@ -195,7 +195,9 @@ public function testToObject() { 'self_notification_email' => false ], 'registration_subsection' => '', - 'enforce_single_session' => false + 'enforce_single_session' => false, + 'verification_code' => null, + 'verification_expiration' => null ]; $this->assertEquals($expected, $actual); }