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: + +![Creation form for initial administration user](assets/initialization-form.png) + +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 = ( +
+
+

{t("title")}

+

{t("adminStep.title")}

+

{t("adminStep.description")}

+
+
+ +
+
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+
+ +
+
+ +
+
+ {errorComponent} +
+
+ +
+
+
+
+ ); + + 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 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(); + } + }