From d9d3547a22645e029fc699fd354d5be66bcb8fe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9=20Pfeuffer?= Date: Thu, 24 Jun 2021 09:29:42 +0200 Subject: [PATCH] Create custom initial user (#1707) Using a default user with a default password has the implicit risk, that this user is not changed and therefore this system can be compromised. With this change, SCM-Manager does not create the default user with the default password on startup any more, but it shows an initial form where the initial values for the administration user have to be entered by the user. To secure this form, a random token is created on startup and printed in the log. To implement this form, the concept of an InitializationStep is introduced. This extension point can be implemented to offer different setup tasks. The creation of the administration user is the first implementation, others might be things like first plugin selections or the like. Frontend components are selected by the name of these initialization steps, whose names will be added to the index resource (whichever is active at the moment) and will be show accordingly. Co-authored-by: Eduard Heimbuch --- .../groovy/com/cloudogu/scm/RunTask.groovy | 2 +- .../assets/initialization-form.png | Bin 0 -> 42088 bytes docs/en/first-startup/index.md | 35 +++ docs/en/navigation.yml | 1 + gradle/changelog/create_initial_user.yaml | 2 + .../InitializationFinisher.java | 34 +++ .../initialization/InitializationStep.java | 37 +++ .../InitializationStepResource.java | 36 +++ scm-ui/ui-types/src/IndexResources.ts | 1 + .../public/locales/de/initialization.json | 17 ++ .../public/locales/en/initialization.json | 17 ++ scm-ui/ui-webapp/src/containers/App.tsx | 8 +- scm-ui/ui-webapp/src/containers/Index.tsx | 4 + .../InitializationAdminAccountStep.tsx | 212 ++++++++++++++++++ .../AdminAccountStartupResource.java | 129 +++++++++++ .../sonia/scm/api/v2/resources/IndexDto.java | 11 +- .../api/v2/resources/IndexDtoGenerator.java | 29 ++- .../api/v2/resources/InitializationDto.java | 36 +++ .../v2/resources/InitializationResource.java | 55 +++++ .../scm/api/v2/resources/ResourceLinks.java | 19 ++ .../DefaultInitializationFinisher.java | 70 ++++++ .../lifecycle/AdminAccountStartupAction.java | 94 +++++++- .../lifecycle/RandomPasswordGenerator.java | 42 ++++ .../lifecycle/modules/ScmServletModule.java | 4 + .../scm/security/JwtAccessTokenRefresher.java | 6 +- .../AdminAccountStartupResourceTest.java | 143 ++++++++++++ .../v2/resources/IndexDtoGeneratorTest.java | 178 ++++++++++----- .../api/v2/resources/IndexResourceTest.java | 9 +- .../AdminAccountStartupActionTest.java | 119 +++++++--- .../security/JwtAccessTokenRefresherTest.java | 16 +- 30 files changed, 1253 insertions(+), 113 deletions(-) create mode 100644 docs/en/first-startup/assets/initialization-form.png create mode 100644 docs/en/first-startup/index.md create mode 100644 gradle/changelog/create_initial_user.yaml create mode 100644 scm-core/src/main/java/sonia/scm/initialization/InitializationFinisher.java create mode 100644 scm-core/src/main/java/sonia/scm/initialization/InitializationStep.java create mode 100644 scm-core/src/main/java/sonia/scm/initialization/InitializationStepResource.java create mode 100644 scm-ui/ui-webapp/public/locales/de/initialization.json create mode 100644 scm-ui/ui-webapp/public/locales/en/initialization.json create mode 100644 scm-ui/ui-webapp/src/containers/InitializationAdminAccountStep.tsx create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/AdminAccountStartupResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationDto.java create mode 100644 scm-webapp/src/main/java/sonia/scm/api/v2/resources/InitializationResource.java create mode 100644 scm-webapp/src/main/java/sonia/scm/initialization/DefaultInitializationFinisher.java create mode 100644 scm-webapp/src/main/java/sonia/scm/lifecycle/RandomPasswordGenerator.java create mode 100644 scm-webapp/src/test/java/sonia/scm/api/v2/resources/AdminAccountStartupResourceTest.java 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 0000000000000000000000000000000000000000..aaf61779dc91dc680d84b4297a316eb3c58b6503 GIT binary patch literal 42088 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 literal 0 HcmV?d00001 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(); + } + }