diff --git a/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy b/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy
index 050bf052c8..23e7580dab 100644
--- a/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy
+++ b/build-plugins/src/main/groovy/com/cloudogu/scm/RunTask.groovy
@@ -109,7 +109,7 @@ class RunTask extends DefaultTask {
args(new File(project.buildDir, 'server/config.json').toString())
environment 'NODE_ENV', 'development'
classpath project.buildscript.configurations.classpath
- systemProperties = ["user.home": extension.getHome()]
+ systemProperties = ["user.home": extension.getHome(), "scm.initialPassword": "scmadmin"]
if (debugJvm) {
debug = true
debugOptions {
diff --git a/docs/en/first-startup/assets/initialization-form.png b/docs/en/first-startup/assets/initialization-form.png
new file mode 100644
index 0000000000..aaf61779dc
Binary files /dev/null and b/docs/en/first-startup/assets/initialization-form.png differ
diff --git a/docs/en/first-startup/index.md b/docs/en/first-startup/index.md
new file mode 100644
index 0000000000..a8851a472a
--- /dev/null
+++ b/docs/en/first-startup/index.md
@@ -0,0 +1,35 @@
+---
+title: First Startup
+subtitle: Administration User Creation
+---
+
+# First Startup
+
+On first startup, you have to create the initial administration user. Therefore, you need the token from the log.
+This log looks something like this:
+
+```
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - ====================================================
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - == ==
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - == Startup token for initial user creation ==
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - == ==
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - == LAh8BzNE68y2fj8Hj9lZ ==
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - == ==
+2021-06-22 09:19:43.166 [main] [ ] WARN sonia.scm.lifecycle.AdminAccountStartupAction - ====================================================
+```
+
+When you open the SCM-Manager URL in a browser, you will see the creation form:
+
+
+
+Enter the token from the log in the first input field and specify the username, the display name, the email address and
+the password for the administration user and click the "Submit" button. When the administration user has been created,
+the page will reload, and you will see the login dialog of SCM-Manager.
+
+The password of the administration user cannot be recovered.
+
+# Bypass User Creation Form
+
+For automated processes, you might want to bypass the initial user creation. To do so, you can set the initial password
+in a system property `scm.initialPassword`. If this is present, a user `scmadmin` with this password will be created,
+if it does not already exist.
diff --git a/docs/en/navigation.yml b/docs/en/navigation.yml
index 0b2dded1e8..e0edf3418d 100644
--- a/docs/en/navigation.yml
+++ b/docs/en/navigation.yml
@@ -1,6 +1,7 @@
- section: Getting started
entries:
- /installation/
+ - /first-startup/
- /migrate-scm-manager-from-v1/
- /import/
- /faq/
diff --git a/gradle/changelog/create_initial_user.yaml b/gradle/changelog/create_initial_user.yaml
new file mode 100644
index 0000000000..bf39802daf
--- /dev/null
+++ b/gradle/changelog/create_initial_user.yaml
@@ -0,0 +1,2 @@
+- type: changed
+ description: Initial admin user has to be created on first startup ([#1707](https://github.com/scm-manager/scm-manager/pull/1707))
diff --git a/scm-core/src/main/java/sonia/scm/initialization/InitializationFinisher.java b/scm-core/src/main/java/sonia/scm/initialization/InitializationFinisher.java
new file mode 100644
index 0000000000..b03407aa83
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/initialization/InitializationFinisher.java
@@ -0,0 +1,34 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.initialization;
+
+public interface InitializationFinisher {
+
+ boolean isFullyInitialized();
+
+ InitializationStep missingInitialization();
+
+ InitializationStepResource getResource(String name);
+}
diff --git a/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java b/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java
new file mode 100644
index 0000000000..6a8c285366
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java
@@ -0,0 +1,37 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.initialization;
+
+import sonia.scm.plugin.ExtensionPoint;
+
+@ExtensionPoint
+public interface InitializationStep {
+
+ String name();
+
+ int sequence();
+
+ boolean done();
+}
diff --git a/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java b/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java
new file mode 100644
index 0000000000..1aa6e68db8
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java
@@ -0,0 +1,36 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.initialization;
+
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.Links;
+import sonia.scm.plugin.ExtensionPoint;
+
+@ExtensionPoint
+public interface InitializationStepResource {
+ String name();
+
+ void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder);
+}
diff --git a/scm-ui/ui-types/src/IndexResources.ts b/scm-ui/ui-types/src/IndexResources.ts
index cea8e9d8c9..1d9a44d73d 100644
--- a/scm-ui/ui-types/src/IndexResources.ts
+++ b/scm-ui/ui-types/src/IndexResources.ts
@@ -26,5 +26,6 @@ import { Links } from "./hal";
export type IndexResources = {
version: string;
+ initialization?: string;
_links: Links;
};
diff --git a/scm-ui/ui-webapp/public/locales/de/initialization.json b/scm-ui/ui-webapp/public/locales/de/initialization.json
new file mode 100644
index 0000000000..edc49f61a3
--- /dev/null
+++ b/scm-ui/ui-webapp/public/locales/de/initialization.json
@@ -0,0 +1,17 @@
+{
+ "title": "Abschluss der Initialisierung",
+ "adminStep": {
+ "title": "Administrations Zugang",
+ "description": "Der Token zur Erstellung des Administrationszugangs befindet sich im Server Log.",
+ "startupToken": "Start-Token",
+ "username": "Administrator Benutzername",
+ "displayname": "Administrator Anzeigename",
+ "email": "E-Mail",
+ "password": "Administrator Passwort",
+ "password-confirmation": "Passwort Bestätigung",
+ "submit": "Absenden"
+ },
+ "error": {
+ "forbidden": "Falscher Token"
+ }
+}
diff --git a/scm-ui/ui-webapp/public/locales/en/initialization.json b/scm-ui/ui-webapp/public/locales/en/initialization.json
new file mode 100644
index 0000000000..3b05afd8e1
--- /dev/null
+++ b/scm-ui/ui-webapp/public/locales/en/initialization.json
@@ -0,0 +1,17 @@
+{
+ "title": "Finish Initialization",
+ "adminStep": {
+ "title": "Administration Account",
+ "description": "Get the initial token from the server log to create your new administration account.",
+ "startupToken": "Startup Token",
+ "username": "Admin Username",
+ "displayname": "Admin Displayname",
+ "email": "E-Mail",
+ "password": "New Password",
+ "password-confirmation": "Confirm Password",
+ "submit": "Submit"
+ },
+ "error": {
+ "forbidden": "Incorrect token"
+ }
+}
diff --git a/scm-ui/ui-webapp/src/containers/App.tsx b/scm-ui/ui-webapp/src/containers/App.tsx
index 3281c0b024..287ddf9e2a 100644
--- a/scm-ui/ui-webapp/src/containers/App.tsx
+++ b/scm-ui/ui-webapp/src/containers/App.tsx
@@ -25,8 +25,9 @@ import React, { FC } from "react";
import Main from "./Main";
import { useTranslation } from "react-i18next";
import { ErrorPage, Footer, Header, Loading, PrimaryNavigation } from "@scm-manager/ui-components";
+import { binder } from "@scm-manager/ui-extensions";
import Login from "./Login";
-import { useSubject, useIndex } from "@scm-manager/ui-api";
+import { useIndex, useSubject } from "@scm-manager/ui-api";
import Notifications from "./Notifications";
const App: FC = () => {
@@ -43,7 +44,10 @@ const App: FC = () => {
// authenticated means authorized, we stick on authenticated for compatibility reasons
const authenticated = isAuthenticated || isAnonymous;
- if (!authenticated && !isLoading) {
+ if (index?.initialization) {
+ const Extension = binder.getExtension(`initialization.step.${index.initialization}`);
+ content = ;
+ } else if (!authenticated && !isLoading) {
content = ;
} else if (isLoading) {
content = ;
diff --git a/scm-ui/ui-webapp/src/containers/Index.tsx b/scm-ui/ui-webapp/src/containers/Index.tsx
index 12edbc7e10..cada9064a9 100644
--- a/scm-ui/ui-webapp/src/containers/Index.tsx
+++ b/scm-ui/ui-webapp/src/containers/Index.tsx
@@ -30,6 +30,8 @@ import IndexErrorPage from "./IndexErrorPage";
import { useIndex } from "@scm-manager/ui-api";
import { Link } from "@scm-manager/ui-types";
import i18next from "i18next";
+import { binder } from "@scm-manager/ui-extensions";
+import InitializationAdminAccountStep from "./InitializationAdminAccountStep";
const Index: FC = () => {
const { isLoading, error, data } = useIndex();
@@ -66,3 +68,5 @@ const Index: FC = () => {
};
export default Index;
+
+binder.bind("initialization.step.adminAccount", InitializationAdminAccountStep);
diff --git a/scm-ui/ui-webapp/src/containers/InitializationAdminAccountStep.tsx b/scm-ui/ui-webapp/src/containers/InitializationAdminAccountStep.tsx
new file mode 100644
index 0000000000..752c739aff
--- /dev/null
+++ b/scm-ui/ui-webapp/src/containers/InitializationAdminAccountStep.tsx
@@ -0,0 +1,212 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import React, { FC, useEffect } from "react";
+import { apiClient, validation, ErrorNotification, InputField, SubmitButton } from "@scm-manager/ui-components";
+import { useTranslation } from "react-i18next";
+import styled from "styled-components";
+import { useMutation } from "react-query";
+import { isDisplayNameValid, isPasswordValid } from "../users/components/userValidation";
+import { Links, Link } from "@scm-manager/ui-types";
+import { useForm } from "react-hook-form";
+
+const HeroSection = styled.section`
+ padding-top: 2em;
+`;
+
+type Props = {
+ data: { _links: Links };
+};
+
+type AdminAccountCreation = {
+ startupToken: string;
+ userName: string;
+ displayName: string;
+ email: string;
+ password: string;
+ passwordConfirmation: string;
+};
+
+const createAdmin = (link: string) => {
+ return (data: AdminAccountCreation) => {
+ return apiClient.post(link, data, "application/json").then(() => {
+ return new Promise((resolve) => resolve());
+ });
+ };
+};
+
+const useCreateAdmin = (link: string) => {
+ const { mutate, isLoading, error, isSuccess } = useMutation(createAdmin(link));
+ return {
+ create: mutate,
+ isLoading,
+ error,
+ isCreated: isSuccess,
+ };
+};
+
+const InitializationAdminAccountStep: FC = ({ data }) => {
+ const [t] = useTranslation("initialization");
+ const { formState, register, handleSubmit, getValues, setError, clearErrors } = useForm({
+ defaultValues: {
+ userName: "scmadmin",
+ displayName: "SCM Administrator",
+ email: "",
+ password: "",
+ passwordConfirmation: "",
+ },
+ mode: "onChange",
+ });
+
+ const { create, isLoading, error, isCreated } = useCreateAdmin((data._links.initialAdminUser as Link).href);
+
+ useEffect(() => {
+ if (isCreated) {
+ window.location.reload(false);
+ }
+ }, [isCreated]);
+
+ const validateUserName = (newUserName: string) => {
+ return validation.isNameValid(newUserName);
+ };
+
+ const validateDisplayName = (newDisplayName: string) => {
+ return isDisplayNameValid(newDisplayName);
+ };
+
+ const validateEmail = (newEmail: string) => {
+ return !newEmail || validation.isMailValid(newEmail);
+ };
+
+ const validatePassword = (newPassword: string) => {
+ if (getValues("passwordConfirmation") !== newPassword) {
+ setError("passwordConfirmation", { type: "manual", message: "does not match password" });
+ } else {
+ clearErrors("passwordConfirmation");
+ }
+ return isPasswordValid(newPassword);
+ };
+
+ const validatePasswordConfirmation = (newPasswordConfirmation: string) => {
+ return newPasswordConfirmation === getValues("password");
+ };
+
+ const onSubmit = (admin: AdminAccountCreation) => {
+ create(admin);
+ };
+
+ let errorComponent;
+ if (error) {
+ if (error.message === "Forbidden") {
+ errorComponent = ;
+ } else {
+ errorComponent = ;
+ }
+ }
+
+ const component = (
+
+
+
+ );
+
+ return (
+
+
+
+
{component}
+
+
+
+ );
+};
+
+export default InitializationAdminAccountStep;
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java
new file mode 100644
index 0000000000..45ad79bf37
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java
@@ -0,0 +1,129 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.Links;
+import lombok.Data;
+import org.apache.shiro.authz.UnauthenticatedException;
+import sonia.scm.initialization.InitializationStepResource;
+import sonia.scm.lifecycle.AdminAccountStartupAction;
+import sonia.scm.plugin.Extension;
+import sonia.scm.security.AllowAnonymousAccess;
+import sonia.scm.util.ValidationUtil;
+
+import javax.inject.Inject;
+import javax.validation.Valid;
+import javax.validation.constraints.Email;
+import javax.validation.constraints.NotEmpty;
+import javax.validation.constraints.Pattern;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+
+import static de.otto.edison.hal.Link.link;
+import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
+
+@AllowAnonymousAccess
+@Extension
+public class AdminAccountStartupResource implements InitializationStepResource {
+
+ private final AdminAccountStartupAction adminAccountStartupAction;
+ private final ResourceLinks resourceLinks;
+
+ @Inject
+ public AdminAccountStartupResource(AdminAccountStartupAction adminAccountStartupAction, ResourceLinks resourceLinks) {
+ this.adminAccountStartupAction = adminAccountStartupAction;
+ this.resourceLinks = resourceLinks;
+ }
+
+ @POST
+ @Path("")
+ @Consumes("application/json")
+ public void postAdminInitializationData(@Valid AdminInitializationData data) {
+ verifyInInitialization();
+ verifyToken(data);
+ createAdminUser(data);
+ }
+
+ private void verifyInInitialization() {
+ doThrow()
+ .violation("initialization not necessary")
+ .when(adminAccountStartupAction.done());
+ }
+
+ private void verifyToken(AdminInitializationData data) {
+ String givenStartupToken = data.getStartupToken();
+
+ if (!adminAccountStartupAction.isCorrectToken(givenStartupToken)) {
+ throw new UnauthenticatedException("wrong password");
+ }
+ }
+
+ private void createAdminUser(AdminInitializationData data) {
+ String userName = data.getUserName();
+ String displayName = data.getDisplayName();
+ String email = data.getEmail();
+ String password = data.getPassword();
+ String passwordConfirmation = data.getPasswordConfirmation();
+
+ verifyPasswordConfirmation(password, passwordConfirmation);
+
+ adminAccountStartupAction.createAdminUser(userName, displayName, email, password);
+ }
+
+ private void verifyPasswordConfirmation(String password, String passwordConfirmation) {
+ doThrow()
+ .violation("password and confirmation differ", "password")
+ .when(!password.equals(passwordConfirmation));
+ }
+
+ @Override
+ public void setupIndex(Links.Builder builder, Embedded.Builder embeddedBuilder) {
+ String link = resourceLinks.initialAdminAccount().indexLink(name());
+ builder.single(link("initialAdminUser", link));
+ }
+
+ @Override
+ public String name() {
+ return adminAccountStartupAction.name();
+ }
+
+ @Data
+ static class AdminInitializationData {
+ @NotEmpty
+ private String startupToken;
+ @Pattern(regexp = ValidationUtil.REGEX_NAME)
+ private String userName;
+ @NotEmpty
+ private String displayName;
+ @Email
+ private String email;
+ @NotEmpty
+ private String password;
+ @NotEmpty
+ private String passwordConfirmation;
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java
index 2039127bf0..ef112e8658 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDto.java
@@ -21,9 +21,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
+import com.fasterxml.jackson.annotation.JsonInclude;
import de.otto.edison.hal.Embedded;
import de.otto.edison.hal.HalRepresentation;
import de.otto.edison.hal.Links;
@@ -34,8 +35,16 @@ public class IndexDto extends HalRepresentation {
private final String version;
+ @JsonInclude(JsonInclude.Include.NON_EMPTY)
+ private final String initialization;
+
IndexDto(Links links, Embedded embedded, String version) {
+ this(links, embedded, version, null);
+ }
+
+ IndexDto(Links links, Embedded embedded, String version, String initialization) {
super(links, embedded);
this.version = version;
+ this.initialization = initialization;
}
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
index 6928f1fd08..826b7558a9 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
@@ -34,6 +34,8 @@ import sonia.scm.SCMContextProvider;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupPermissions;
+import sonia.scm.initialization.InitializationFinisher;
+import sonia.scm.initialization.InitializationStep;
import sonia.scm.plugin.PluginPermissions;
import sonia.scm.security.AnonymousMode;
import sonia.scm.security.Authentications;
@@ -52,20 +54,32 @@ public class IndexDtoGenerator extends HalAppenderMapper {
private final ResourceLinks resourceLinks;
private final SCMContextProvider scmContextProvider;
private final ScmConfiguration configuration;
+ private final InitializationFinisher initializationFinisher;
@Inject
- public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider, ScmConfiguration configuration) {
+ public IndexDtoGenerator(ResourceLinks resourceLinks, SCMContextProvider scmContextProvider, ScmConfiguration configuration, InitializationFinisher initializationFinisher) {
this.resourceLinks = resourceLinks;
this.scmContextProvider = scmContextProvider;
this.configuration = configuration;
+ this.initializationFinisher = initializationFinisher;
}
public IndexDto generate() {
Links.Builder builder = Links.linkingTo();
- List autoCompleteLinks = Lists.newArrayList();
+ Embedded.Builder embeddedBuilder = embeddedBuilder();
+
builder.self(resourceLinks.index().self());
builder.single(link("uiPlugins", resourceLinks.uiPluginCollection().self()));
+ if (initializationFinisher.isFullyInitialized()) {
+ return handleNormalIndex(builder, embeddedBuilder);
+ } else {
+ return handleInitialization(builder, embeddedBuilder);
+ }
+ }
+
+ private IndexDto handleNormalIndex(Links.Builder builder, Embedded.Builder embeddedBuilder) {
+ List autoCompleteLinks = Lists.newArrayList();
String loginInfoUrl = configuration.getLoginInfoUrl();
if (!Strings.isNullOrEmpty(loginInfoUrl)) {
builder.single(link("loginInfo", loginInfoUrl));
@@ -121,12 +135,19 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("login", resourceLinks.authentication().jsonLogin()));
}
- Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(builder, embeddedBuilder), new Index());
-
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion());
}
+ private IndexDto handleInitialization(Links.Builder builder, Embedded.Builder embeddedBuilder) {
+ Links.Builder initializationLinkBuilder = Links.linkingTo();
+ Embedded.Builder initializationEmbeddedBuilder = embeddedBuilder();
+ InitializationStep initializationStep = initializationFinisher.missingInitialization();
+ initializationFinisher.getResource(initializationStep.name()).setupIndex(initializationLinkBuilder, initializationEmbeddedBuilder);
+ embeddedBuilder.with(initializationStep.name(), new InitializationDto(initializationLinkBuilder.build(), initializationEmbeddedBuilder.build()));
+ return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion(), initializationStep.name());
+ }
+
private boolean shouldAppendSubjectRelatedLinks() {
return isAuthenticatedSubjectNotAnonymous()
|| isAuthenticatedSubjectAllowedToBeAnonymous();
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationDto.java
new file mode 100644
index 0000000000..4d773e6b91
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationDto.java
@@ -0,0 +1,36 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Links;
+
+public class InitializationDto extends HalRepresentation {
+
+ public InitializationDto(Links links, Embedded embedded) {
+ super(links, embedded);
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationResource.java
new file mode 100644
index 0000000000..1bd3ad77a2
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationResource.java
@@ -0,0 +1,55 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import sonia.scm.initialization.InitializationStep;
+import sonia.scm.initialization.InitializationStepResource;
+
+import javax.inject.Inject;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import java.util.Set;
+
+import static sonia.scm.ContextEntry.ContextBuilder.entity;
+import static sonia.scm.NotFoundException.notFound;
+
+@Path("v2/initialization/")
+public class InitializationResource {
+
+ private final Set steps;
+
+ @Inject
+ public InitializationResource(Set steps) {
+ this.steps = steps;
+ }
+
+ @Path("{stepName}")
+ public InitializationStepResource step(@PathParam("stepName") String stepName) {
+ return steps.stream()
+ .filter(step -> stepName.equals(step.name()))
+ .findFirst()
+ .orElseThrow(() -> notFound(entity(InitializationStep.class, stepName)));
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
index 43b53915c8..726e33eb72 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
@@ -1112,4 +1112,23 @@ class ResourceLinks {
return metricsLinkBuilder.method("metrics").parameters(type).href();
}
}
+
+ public InitialAdminAccountLinks initialAdminAccount() {
+ return new InitialAdminAccountLinks(new LinkBuilder(scmPathInfoStore.get(), InitializationResource.class, AdminAccountStartupResource.class));
+ }
+
+ public static class InitialAdminAccountLinks {
+ private final LinkBuilder initializationLinkBuilder;
+
+ private InitialAdminAccountLinks(LinkBuilder initializationLinkBuilder) {
+ this.initializationLinkBuilder = initializationLinkBuilder;
+ }
+
+ public String indexLink(String stepName) {
+ return initializationLinkBuilder
+ .method("step").parameters(stepName)
+ .method("postAdminInitializationData").parameters()
+ .href();
+ }
+ }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/initialization/DefaultInitializationFinisher.java b/scm-webapp/src/main/java/sonia/scm/initialization/DefaultInitializationFinisher.java
new file mode 100644
index 0000000000..b867906a9f
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/initialization/DefaultInitializationFinisher.java
@@ -0,0 +1,70 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.initialization;
+
+import sonia.scm.EagerSingleton;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+import java.util.List;
+import java.util.Set;
+
+import static java.util.Comparator.comparing;
+import static java.util.stream.Collectors.toList;
+
+@EagerSingleton
+public class DefaultInitializationFinisher implements InitializationFinisher {
+
+ private final List steps;
+ private final Provider> resources;
+
+ @Inject
+ public DefaultInitializationFinisher(Set steps, Provider> resources) {
+ this.steps = steps.stream().sorted(comparing(InitializationStep::sequence)).collect(toList());
+ this.resources = resources;
+ }
+
+ @Override
+ public boolean isFullyInitialized() {
+ return steps.stream().allMatch(InitializationStep::done);
+ }
+
+ @Override
+ public InitializationStep missingInitialization() {
+ return steps
+ .stream()
+ .filter(step -> !step.done()).findFirst()
+ .orElseThrow(() -> new IllegalStateException("all steps initialized"));
+ }
+
+ @Override
+ public InitializationStepResource getResource(String name) {
+ return resources.get()
+ .stream()
+ .filter(resource -> name.equals(resource.name()))
+ .findFirst()
+ .orElseThrow(() -> new IllegalStateException("resource not found for initialization step " + name));
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java
index b01070bd52..451b89d908 100644
--- a/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java
+++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/AdminAccountStartupAction.java
@@ -25,45 +25,111 @@
package sonia.scm.lifecycle;
import org.apache.shiro.authc.credential.PasswordService;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import sonia.scm.SCMContext;
+import sonia.scm.initialization.InitializationStep;
import sonia.scm.plugin.Extension;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
+import sonia.scm.web.security.AdministrationContext;
import javax.inject.Inject;
+import javax.inject.Singleton;
import java.util.Collections;
+import static sonia.scm.ScmConstraintViolationException.Builder.doThrow;
+
@Extension
-public class AdminAccountStartupAction implements PrivilegedStartupAction {
+@Singleton
+public class AdminAccountStartupAction implements InitializationStep {
+
+ private static final Logger LOG = LoggerFactory.getLogger(AdminAccountStartupAction.class);
+
+ private static final String INITIAL_PASSWORD_PROPERTY = "scm.initialPassword";
private final PasswordService passwordService;
private final UserManager userManager;
private final PermissionAssigner permissionAssigner;
+ private final RandomPasswordGenerator randomPasswordGenerator;
+ private final AdministrationContext context;
+
+ private String initialToken;
@Inject
- public AdminAccountStartupAction(PasswordService passwordService, UserManager userManager, PermissionAssigner permissionAssigner) {
+ public AdminAccountStartupAction(PasswordService passwordService, UserManager userManager, PermissionAssigner permissionAssigner, RandomPasswordGenerator randomPasswordGenerator, AdministrationContext context) {
this.passwordService = passwordService;
this.userManager = userManager;
this.permissionAssigner = permissionAssigner;
+ this.randomPasswordGenerator = randomPasswordGenerator;
+ this.context = context;
+
+ initialize();
}
- @Override
- public void run() {
- if (shouldCreateAdminAccount()) {
- createAdminAccount();
+ private void initialize() {
+ context.runAsAdmin((PrivilegedStartupAction)() -> {
+ if (shouldCreateAdminAccount() && !adminUserCreatedWithGivenPassword()) {
+ createStartupToken();
+ }
+ });
+ }
+
+ private boolean adminUserCreatedWithGivenPassword() {
+ String startupTokenByProperty = System.getProperty(INITIAL_PASSWORD_PROPERTY);
+ if (startupTokenByProperty != null) {
+ context.runAsAdmin((PrivilegedStartupAction) () ->
+ createAdminUser("scmadmin", "SCM Administrator", "scm-admin@scm-manager.org", startupTokenByProperty));
+ LOG.info("=================================================");
+ LOG.info("== ==");
+ LOG.info("== Created user 'scmadmin' with given password ==");
+ LOG.info("== ==");
+ LOG.info("=================================================");
+ return true;
+ } else {
+ return false;
}
}
- private void createAdminAccount() {
- User scmadmin = new User("scmadmin", "SCM Administrator", "scm-admin@scm-manager.org");
- String password = passwordService.encryptPassword("scmadmin");
- scmadmin.setPassword(password);
- userManager.create(scmadmin);
+ @Override
+ public String name() {
+ return "adminAccount";
+ }
+ @Override
+ public int sequence() {
+ return 0;
+ }
+
+ @Override
+ public boolean done() {
+ return initialToken == null;
+ }
+
+ public void createAdminUser(String userName, String displayName, String email, String password) {
+ User admin = new User(userName, displayName, email);
+ String encryptedPassword = passwordService.encryptPassword(password);
+ admin.setPassword(encryptedPassword);
+ doThrow().violation("invalid user name").when(!admin.isValid());
PermissionDescriptor descriptor = new PermissionDescriptor("*");
- permissionAssigner.setPermissionsForUser("scmadmin", Collections.singleton(descriptor));
+ context.runAsAdmin((PrivilegedStartupAction) () -> {
+ userManager.create(admin);
+ permissionAssigner.setPermissionsForUser(userName, Collections.singleton(descriptor));
+ initialToken = null;
+ });
+ }
+
+ private void createStartupToken() {
+ initialToken = randomPasswordGenerator.createRandomPassword();
+ LOG.warn("====================================================");
+ LOG.warn("== ==");
+ LOG.warn("== Startup token for initial user creation ==");
+ LOG.warn("== ==");
+ LOG.warn("== {} ==", initialToken);
+ LOG.warn("== ==");
+ LOG.warn("====================================================");
}
private boolean shouldCreateAdminAccount() {
@@ -73,4 +139,8 @@ public class AdminAccountStartupAction implements PrivilegedStartupAction {
private boolean onlyAnonymousUserExists() {
return userManager.getAll().size() == 1 && userManager.contains(SCMContext.USER_ANONYMOUS);
}
+
+ public boolean isCorrectToken(String givenStartupToken) {
+ return initialToken.equals(givenStartupToken);
+ }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/RandomPasswordGenerator.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/RandomPasswordGenerator.java
new file mode 100644
index 0000000000..33e3d0a7bb
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/RandomPasswordGenerator.java
@@ -0,0 +1,42 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.lifecycle;
+
+import org.apache.commons.lang3.RandomStringUtils;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.SecureRandom;
+
+final class RandomPasswordGenerator {
+
+ String createRandomPassword() {
+ try {
+ SecureRandom random = SecureRandom.getInstanceStrong();
+ return RandomStringUtils.random(20, 0, 0, true, true, null, random);
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("Every Java distribution is required to support a strong secure random generator; this should not have happened", e);
+ }
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java
index 482068c324..98445a62a6 100644
--- a/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java
+++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/modules/ScmServletModule.java
@@ -57,6 +57,8 @@ import sonia.scm.group.GroupDisplayManager;
import sonia.scm.group.GroupManager;
import sonia.scm.group.GroupManagerProvider;
import sonia.scm.group.xml.XmlGroupDAO;
+import sonia.scm.initialization.DefaultInitializationFinisher;
+import sonia.scm.initialization.InitializationFinisher;
import sonia.scm.metrics.MeterRegistryProvider;
import sonia.scm.migration.MigrationDAO;
import sonia.scm.net.SSLContextProvider;
@@ -275,6 +277,8 @@ class ScmServletModule extends ServletModule {
bind(HealthCheckService.class).to(DefaultHealthCheckService.class);
bind(NotificationSender.class).to(DefaultNotificationSender.class);
+
+ bind(InitializationFinisher.class).to(DefaultInitializationFinisher.class);
}
private void bind(Class clazz, Class extends T> defaultImplementation) {
diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java
index b32fe431ab..0301c0fc44 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenRefresher.java
@@ -21,9 +21,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.security;
+import org.apache.shiro.SecurityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -80,7 +81,8 @@ public class JwtAccessTokenRefresher {
}
private boolean canBeRefreshed(JwtAccessToken oldToken) {
- return tokenIsValid(oldToken) && tokenCanBeRefreshed(oldToken);
+ return tokenIsValid(oldToken) && tokenCanBeRefreshed(oldToken)
+ && SecurityUtils.getSubject().getPrincipals() != null;
}
private boolean shouldBeRefreshed(JwtAccessToken oldToken) {
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java
new file mode 100644
index 0000000000..583f20f0ee
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java
@@ -0,0 +1,143 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import org.jboss.resteasy.mock.MockHttpRequest;
+import org.jboss.resteasy.mock.MockHttpResponse;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.lifecycle.AdminAccountStartupAction;
+import sonia.scm.web.RestDispatcher;
+
+import javax.inject.Provider;
+import java.net.URISyntaxException;
+
+import static java.lang.String.format;
+import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.Collections.singleton;
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.jboss.resteasy.mock.MockHttpRequest.post;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class AdminAccountStartupResourceTest {
+
+ private final RestDispatcher dispatcher = new RestDispatcher();
+ private final MockHttpResponse response = new MockHttpResponse();
+
+ @Mock
+ private AdminAccountStartupAction startupAction;
+ @Mock
+ private Provider pathInfoStoreProvider;
+ @Mock
+ private ScmPathInfoStore pathInfoStore;
+ @Mock
+ private ScmPathInfo pathInfo;
+
+ @InjectMocks
+ private AdminAccountStartupResource resource;
+
+ @BeforeEach
+ void setUpMocks() {
+ lenient().when(pathInfoStoreProvider.get()).thenReturn(pathInfoStore);
+ lenient().when(pathInfoStore.get()).thenReturn(pathInfo);
+ dispatcher.addSingletonResource(new InitializationResource(singleton(resource)));
+ lenient().when(startupAction.name()).thenReturn("adminAccount");
+ }
+
+ @Test
+ void shouldFailWhenActionIsDone() throws URISyntaxException {
+ when(startupAction.done()).thenReturn(true);
+
+ MockHttpRequest request =
+ post("/v2/initialization/adminAccount")
+ .contentType("application/json")
+ .content(createInput("irrelevant", "irrelevant", "irrelevant", "irrelevant@some.com", "irrelevant", "irrelevant"));
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(400);
+ }
+
+ @Nested
+ class WithNecessaryAction {
+
+ @BeforeEach
+ void actionNotDone() {
+ when(startupAction.done()).thenReturn(false);
+ when(startupAction.isCorrectToken(any())).thenAnswer(i -> "initial-token".equals(i.getArgument(0)));
+ }
+
+ @Test
+ void shouldFailWithWrongToken() throws URISyntaxException {
+ MockHttpRequest request =
+ post("/v2/initialization/adminAccount")
+ .contentType("application/json")
+ .content(createInput("wrong-token", "trillian", "Tricia", "tricia@hitchhiker.com", "something", "different"));
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(403);
+ }
+
+ @Test
+ void shouldFailWhenPasswordsAreNotEqual() throws URISyntaxException {
+ MockHttpRequest request =
+ post("/v2/initialization/adminAccount")
+ .contentType("application/json")
+ .content(createInput("initial-token", "trillian", "Tricia", "tricia@hitchhiker.com", "something", "different"));
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(400);
+ }
+
+ @Test
+ void shouldCreateAdminUser() throws URISyntaxException {
+ MockHttpRequest request =
+ post("/v2/initialization/adminAccount")
+ .contentType("application/json")
+ .content(createInput("initial-token", "trillian", "Tricia", "tricia@hitchhiker.com", "password", "password"));
+ dispatcher.invoke(request, response);
+
+ assertThat(response.getStatus()).isEqualTo(204);
+
+ verify(startupAction).createAdminUser("trillian", "Tricia", "tricia@hitchhiker.com", "password");
+ }
+ }
+
+ private byte[] createInput(String token, String userName, String displayName, String email, String password, String confirmation) {
+ return json(format("{'startupToken': '%s', 'userName': '%s', 'displayName': '%s', 'email': '%s', 'password': '%s', 'passwordConfirmation': '%s'}", token, userName, displayName, email, password, confirmation));
+ }
+
+ private byte[] json(String s) {
+ return s.replaceAll("'", "\"").getBytes(UTF_8);
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java
index 118188dbfc..c9fff0666e 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java
@@ -24,10 +24,13 @@
package sonia.scm.api.v2.resources;
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.Links;
import org.apache.shiro.subject.Subject;
import org.apache.shiro.util.ThreadContext;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
@@ -35,11 +38,18 @@ import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.BasicContextProvider;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.initialization.InitializationFinisher;
+import sonia.scm.initialization.InitializationStep;
+import sonia.scm.initialization.InitializationStepResource;
import sonia.scm.security.AnonymousMode;
import java.net.URI;
+import java.util.List;
+import static de.otto.edison.hal.Link.link;
import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.when;
import static sonia.scm.SCMContext.USER_ANONYMOUS;
@@ -49,80 +59,130 @@ class IndexDtoGeneratorTest {
private static final ScmPathInfo scmPathInfo = () -> URI.create("/api/v2");
@Mock
- private ScmConfiguration configuration;
+ private ResourceLinks resourceLinks;
@Mock
private BasicContextProvider contextProvider;
@Mock
- private ResourceLinks resourceLinks;
-
+ private ScmConfiguration configuration;
@Mock
- private Subject subject;
+ private InitializationFinisher initializationFinisher;
@InjectMocks
private IndexDtoGenerator generator;
- @BeforeEach
- void bindSubject() {
- ThreadContext.bind(subject);
+ @Nested
+ class WithFullyInitializedSystem {
+
+ @Mock
+ private Subject subject;
+
+ @BeforeEach
+ void fullyInitialized() {
+ when(initializationFinisher.isFullyInitialized()).thenReturn(true);
+ }
+
+ @BeforeEach
+ void bindSubject() {
+ ThreadContext.bind(subject);
+ }
+
+ @AfterEach
+ void tearDownSubject() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Test
+ void shouldAppendMeIfAuthenticated() {
+ mockSubjectRelatedResourceLinks();
+ when(subject.isAuthenticated()).thenReturn(true);
+
+ when(contextProvider.getVersion()).thenReturn("2.x");
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isPresent();
+ }
+
+ @Test
+ void shouldNotAppendMeIfUserIsAuthenticatedButAnonymous() {
+ mockResourceLinks();
+ when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
+ when(subject.isAuthenticated()).thenReturn(true);
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
+ }
+
+ @Test
+ void shouldAppendMeIfUserIsAnonymousAndAnonymousModeIsFullEnabled() {
+ mockSubjectRelatedResourceLinks();
+ when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
+ when(subject.isAuthenticated()).thenReturn(true);
+ when(configuration.getAnonymousMode()).thenReturn(AnonymousMode.FULL);
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isPresent();
+ }
+
+ @Test
+ void shouldNotAppendMeIfUserIsAnonymousAndAnonymousModeIsProtocolOnly() {
+ mockResourceLinks();
+ when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
+ when(subject.isAuthenticated()).thenReturn(true);
+ when(configuration.getAnonymousMode()).thenReturn(AnonymousMode.PROTOCOL_ONLY);
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
+ }
}
- @AfterEach
- void tearDownSubject() {
- ThreadContext.unbindSubject();
+ @Nested
+ class WithUnfinishedInitialization {
+
+ @Mock
+ private InitializationStep initializationStep;
+ @Mock
+ private InitializationStepResource initializationStepResource;
+
+ @Test
+ void shouldCreateInitializationLink() {
+ mockBaseLink();
+ when(initializationFinisher.isFullyInitialized()).thenReturn(false);
+ when(initializationFinisher.missingInitialization()).thenReturn(initializationStep);
+ when(initializationStep.name()).thenReturn("probability");
+ when(initializationFinisher.getResource("probability")).thenReturn(initializationStepResource);
+
+ doAnswer(invocationOnMock -> {
+ Links.Builder initializationLinkBuilder = invocationOnMock.getArgument(0, Links.Builder.class);
+ Embedded.Builder initializationEmbeddedBuilder = invocationOnMock.getArgument(1, Embedded.Builder.class);
+ initializationLinkBuilder.single(link("init", "/init"));
+ return null;
+ }).when(initializationStepResource).setupIndex(any(), any());
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getInitialization()).isEqualTo("probability");
+ List initializationDtos = dto.getEmbedded().getItemsBy("probability", InitializationDto.class);
+ assertThat(initializationDtos).hasSize(1).allMatch(
+ initializationDto -> {
+ assertThat(initializationDto.getLinks().hasLink("init")).isTrue();
+ return true;
+ }
+ );
+ }
}
- @Test
- void shouldAppendMeIfAuthenticated() {
- mockSubjectRelatedResourceLinks();
- when(subject.isAuthenticated()).thenReturn(true);
-
- when(contextProvider.getVersion()).thenReturn("2.x");
-
- IndexDto dto = generator.generate();
-
- assertThat(dto.getLinks().getLinkBy("me")).isPresent();
- }
-
- @Test
- void shouldNotAppendMeIfUserIsAuthenticatedButAnonymous() {
- mockResourceLinks();
- when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
- when(subject.isAuthenticated()).thenReturn(true);
-
- IndexDto dto = generator.generate();
-
- assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
- }
-
- @Test
- void shouldAppendMeIfUserIsAnonymousAndAnonymousModeIsFullEnabled() {
- mockSubjectRelatedResourceLinks();
- when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
- when(subject.isAuthenticated()).thenReturn(true);
- when(configuration.getAnonymousMode()).thenReturn(AnonymousMode.FULL);
-
- IndexDto dto = generator.generate();
-
- assertThat(dto.getLinks().getLinkBy("me")).isPresent();
- }
-
- @Test
- void shouldNotAppendMeIfUserIsAnonymousAndAnonymousModeIsProtocolOnly() {
- mockResourceLinks();
- when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
- when(subject.isAuthenticated()).thenReturn(true);
- when(configuration.getAnonymousMode()).thenReturn(AnonymousMode.PROTOCOL_ONLY);
-
- IndexDto dto = generator.generate();
-
- assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
- }
-
-
private void mockResourceLinks() {
+ mockBaseLink();
+ when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(scmPathInfo));
+ }
+
+ private void mockBaseLink() {
when(resourceLinks.index()).thenReturn(new ResourceLinks.IndexLinks(scmPathInfo));
when(resourceLinks.uiPluginCollection()).thenReturn(new ResourceLinks.UIPluginCollectionLinks(scmPathInfo));
- when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(scmPathInfo));
}
private void mockSubjectRelatedResourceLinks() {
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java
index a0876db37a..b4fb98ff79 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexResourceTest.java
@@ -32,6 +32,9 @@ import org.junit.Rule;
import org.junit.Test;
import sonia.scm.SCMContextProvider;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.initialization.InitializationFinisher;
+import sonia.scm.initialization.InitializationStep;
+import sonia.scm.initialization.InitializationStepResource;
import java.net.URI;
import java.util.Optional;
@@ -54,11 +57,13 @@ public class IndexResourceTest {
public void setUpObjectUnderTest() {
this.configuration = new ScmConfiguration();
this.scmContextProvider = mock(SCMContextProvider.class);
+ InitializationFinisher initializationFinisher = mock(InitializationFinisher.class);
+ when(initializationFinisher.isFullyInitialized()).thenReturn(true);
IndexDtoGenerator generator = new IndexDtoGenerator(
ResourceLinksMock.createMock(URI.create("/")),
scmContextProvider,
- configuration
- );
+ configuration,
+ initializationFinisher);
this.indexResource = new IndexResource(generator);
}
diff --git a/scm-webapp/src/test/java/sonia/scm/lifecycle/AdminAccountStartupActionTest.java b/scm-webapp/src/test/java/sonia/scm/lifecycle/AdminAccountStartupActionTest.java
index 47f1a09aea..567c63df20 100644
--- a/scm-webapp/src/test/java/sonia/scm/lifecycle/AdminAccountStartupActionTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/lifecycle/AdminAccountStartupActionTest.java
@@ -26,10 +26,11 @@ package sonia.scm.lifecycle;
import com.google.common.collect.Lists;
import org.apache.shiro.authc.credential.PasswordService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.ArgumentCaptor;
-import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.mockito.junit.jupiter.MockitoSettings;
@@ -40,13 +41,17 @@ import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
import sonia.scm.user.UserTestData;
+import sonia.scm.web.security.AdministrationContext;
import java.util.Collection;
+import java.util.Collections;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@@ -60,30 +65,89 @@ class AdminAccountStartupActionTest {
private UserManager userManager;
@Mock
private PermissionAssigner permissionAssigner;
+ @Mock
+ private RandomPasswordGenerator randomPasswordGenerator;
+ @Mock
+ private AdministrationContext context;
- @InjectMocks
- private AdminAccountStartupAction startupAction;
+ AdminAccountStartupAction startupAction;
- @Test
- void shouldCreateAdminAccountIfNoUserExistsAndAssignPermissions() {
- when(passwordService.encryptPassword("scmadmin")).thenReturn("secret");
+ ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class);
- startupAction.run();
+ @BeforeEach
+ void clearProperties() {
+ System.clearProperty("scm.initialPassword");
+ System.clearProperty("sonia.scm.skipAdminCreation");
- verifyAdminCreated();
- verifyAdminPermissionsAssigned();
+ }
+
+ @BeforeEach
+ void mockAdminContext() {
+ doAnswer(invocation -> {
+ invocation.getArgument(0, PrivilegedStartupAction.class).run();
+ return null;
+ }).when(context).runAsAdmin(any(PrivilegedStartupAction.class));
+ }
+
+ @BeforeEach
+ void setUpUserCaptor() {
+ lenient().when(userManager.create(userCaptor.capture())).thenAnswer(i -> i.getArgument(0));
+ }
+
+ @Nested
+ class WithPredefinedPassword {
+ @BeforeEach
+ void initPasswordGenerator() {
+ System.setProperty("scm.initialPassword", "password");
+ lenient().when(passwordService.encryptPassword("password")).thenReturn("encrypted");
+ }
+
+ @Test
+ void shouldCreateAdminAccountIfNoUserExistsAndAssignPermissions() {
+ createStartupAction();
+
+ verifyAdminCreated();
+ verifyAdminPermissionsAssigned();
+ assertThat(startupAction.done()).isTrue();
+ }
+
+ @Test
+ void shouldCreateAdminAccountIfOnlyAnonymousUserExistsAndAssignPermissions() {
+ when(userManager.getAll()).thenReturn(Lists.newArrayList(SCMContext.ANONYMOUS));
+ when(userManager.contains(SCMContext.USER_ANONYMOUS)).thenReturn(true);
+
+ createStartupAction();
+
+ verifyAdminCreated();
+ verifyAdminPermissionsAssigned();
+ assertThat(startupAction.done()).isTrue();
+ }
+
+ @Test
+ void shouldDoNothingOnSecondStart() {
+ List users = Lists.newArrayList(UserTestData.createTrillian());
+ when(userManager.getAll()).thenReturn(users);
+
+ createStartupAction();
+
+ verify(userManager, never()).create(any(User.class));
+ verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any());
+ assertThat(startupAction.done()).isTrue();
+ }
}
@Test
- void shouldCreateAdminAccountIfOnlyAnonymousUserExistsAndAssignPermissions() {
- when(userManager.getAll()).thenReturn(Lists.newArrayList(SCMContext.ANONYMOUS));
- when(userManager.contains(SCMContext.USER_ANONYMOUS)).thenReturn(true);
- when(passwordService.encryptPassword("scmadmin")).thenReturn("secret");
+ void shouldCreateStartupToken() {
+ lenient().when(randomPasswordGenerator.createRandomPassword()).thenReturn("random");
+ when(userManager.getAll()).thenReturn(Collections.emptyList());
- startupAction.run();
+ createStartupAction();
- verifyAdminCreated();
- verifyAdminPermissionsAssigned();
+ verify(userManager, never()).create(any(User.class));
+ verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any());
+ assertThat(startupAction.done()).isFalse();
+ assertThat(startupAction.isCorrectToken("random")).isTrue();
+ assertThat(startupAction.isCorrectToken("wrong")).isFalse();
}
@Test
@@ -91,14 +155,10 @@ class AdminAccountStartupActionTest {
void shouldSkipAdminAccountCreationIfPropertyIsSet() {
System.setProperty("sonia.scm.skipAdminCreation", "true");
- try {
- startupAction.run();
+ createStartupAction();
- verify(userManager, never()).create(any());
- verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any(Collection.class));
- } finally {
- System.setProperty("sonia.scm.skipAdminCreation", "");
- }
+ verify(userManager, never()).create(any());
+ verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any());
}
@Test
@@ -106,10 +166,15 @@ class AdminAccountStartupActionTest {
List users = Lists.newArrayList(UserTestData.createTrillian());
when(userManager.getAll()).thenReturn(users);
- startupAction.run();
+ createStartupAction();
verify(userManager, never()).create(any(User.class));
- verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any(Collection.class));
+ verify(permissionAssigner, never()).setPermissionsForUser(anyString(), any());
+ assertThat(startupAction.done()).isTrue();
+ }
+
+ private void createStartupAction() {
+ startupAction = new AdminAccountStartupAction(passwordService, userManager, permissionAssigner, randomPasswordGenerator, context);
}
private void verifyAdminPermissionsAssigned() {
@@ -123,10 +188,8 @@ class AdminAccountStartupActionTest {
}
private void verifyAdminCreated() {
- ArgumentCaptor userCaptor = ArgumentCaptor.forClass(User.class);
- verify(userManager).create(userCaptor.capture());
User user = userCaptor.getValue();
assertThat(user.getName()).isEqualTo("scmadmin");
- assertThat(user.getPassword()).isEqualTo("secret");
+ assertThat(user.getPassword()).isEqualTo("encrypted");
}
}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java
index b8d809bff7..9ba7739343 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenRefresherTest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.security;
import org.apache.shiro.subject.Subject;
@@ -52,7 +52,7 @@ import static org.mockito.Mockito.when;
import static sonia.scm.security.SecureKeyTestUtil.createSecureKey;
@ExtendWith(MockitoExtension.class)
-public class JwtAccessTokenRefresherTest {
+class JwtAccessTokenRefresherTest {
private static final Instant NOW = Instant.now().truncatedTo(SECONDS);
private static final Instant TOKEN_CREATION = NOW.minus(ofMinutes(1));
@@ -182,4 +182,16 @@ public class JwtAccessTokenRefresherTest {
JwtAccessToken refreshedToken = refreshedTokenResult.get();
assertThat(refreshedToken.getRefreshExpiration()).get().isEqualTo(Date.from(TOKEN_CREATION.plus(ofMinutes(10))));
}
+
+ @Test
+ void shouldNotRefreshTokenWhenPrincipalIsMissing() {
+ JwtAccessToken oldToken = tokenBuilder.build();
+
+ when(subject.getPrincipals()).thenReturn(null);
+
+ Optional refreshedTokenResult = refresher.refresh(oldToken);
+
+ assertThat(refreshedTokenResult).isEmpty();
+ }
+
}