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 <eduard.heimbuch@cloudogu.com>
This commit is contained in:
René Pfeuffer
2021-06-24 09:29:42 +02:00
committed by GitHub
parent d6e36e7145
commit d9d3547a22
30 changed files with 1253 additions and 113 deletions

View File

@@ -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 {

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

View File

@@ -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.

View File

@@ -1,6 +1,7 @@
- section: Getting started
entries:
- /installation/
- /first-startup/
- /migrate-scm-manager-from-v1/
- /import/
- /faq/

View File

@@ -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))

View File

@@ -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);
}

View File

@@ -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();
}

View File

@@ -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);
}

View File

@@ -26,5 +26,6 @@ import { Links } from "./hal";
export type IndexResources = {
version: string;
initialization?: string;
_links: Links;
};

View File

@@ -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"
}
}

View File

@@ -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"
}
}

View File

@@ -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 = <Extension data={index._embedded[index.initialization]} />;
} else if (!authenticated && !isLoading) {
content = <Login />;
} else if (isLoading) {
content = <Loading />;

View File

@@ -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);

View File

@@ -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<void>((resolve) => resolve());
});
};
};
const useCreateAdmin = (link: string) => {
const { mutate, isLoading, error, isSuccess } = useMutation<void, Error, AdminAccountCreation>(createAdmin(link));
return {
create: mutate,
isLoading,
error,
isCreated: isSuccess,
};
};
const InitializationAdminAccountStep: FC<Props> = ({ data }) => {
const [t] = useTranslation("initialization");
const { formState, register, handleSubmit, getValues, setError, clearErrors } = useForm<AdminAccountCreation>({
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 = <ErrorNotification error={new Error(t("error.forbidden"))} />;
} else {
errorComponent = <ErrorNotification error={error} />;
}
}
const component = (
<div className="column is-8 box has-background-white-ter">
<form onSubmit={handleSubmit(onSubmit)}>
<h3 className="title">{t("title")}</h3>
<h4 className="subtitle">{t("adminStep.title")}</h4>
<p>{t("adminStep.description")}</p>
<div className={"columns"}>
<div className="column is-full-width">
<InputField placeholder={t("adminStep.startupToken")} autofocus={true} {...register("startupToken")} />
</div>
</div>
<div className={"columns"}>
<div className="column is-half">
<InputField
testId="username-input"
label={t("adminStep.username")}
validationError={!!formState.errors.userName}
{...register("userName", { validate: validateUserName })}
/>
</div>
<div className="column is-half">
<InputField
testId="displayname-input"
label={t("adminStep.displayname")}
validationError={!!formState.errors.displayName}
{...register("displayName", { validate: validateDisplayName })}
/>
</div>
</div>
<div className={"columns"}>
<div className="column is-full-width">
<InputField
label={t("adminStep.email")}
validationError={!!formState.errors.email}
{...register("email", { validate: validateEmail })}
/>
</div>
</div>
<div className={"columns"}>
<div className="column is-half">
<InputField
testId="password-input"
label={t("adminStep.password")}
type="password"
validationError={!!formState.errors.password}
{...register("password", { validate: validatePassword })}
/>
</div>
<div className="column is-half">
<InputField
testId="password-confirmation-input"
label={t("adminStep.password-confirmation")}
type="password"
validationError={!!formState.errors.passwordConfirmation}
{...register("passwordConfirmation", { validate: validatePasswordConfirmation })}
/>
</div>
</div>
{errorComponent}
<div className={"columns"}>
<div className="column is-full-width">
<SubmitButton
label={t("adminStep.submit")}
fullWidth={true}
loading={isLoading}
disabled={!formState.isValid}
/>
</div>
</div>
</form>
</div>
);
return (
<HeroSection className="hero">
<div className="hero-body">
<div className="container">
<div className="columns is-centered">{component}</div>
</div>
</div>
</HeroSection>
);
};
export default InitializationAdminAccountStep;

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View File

@@ -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<Link> 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<Link> 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();

View File

@@ -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);
}
}

View File

@@ -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<InitializationStepResource> steps;
@Inject
public InitializationResource(Set<InitializationStepResource> 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)));
}
}

View File

@@ -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();
}
}
}

View File

@@ -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<InitializationStep> steps;
private final Provider<Set<InitializationStepResource>> resources;
@Inject
public DefaultInitializationFinisher(Set<InitializationStep> steps, Provider<Set<InitializationStepResource>> 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));
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}
}

View File

@@ -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 <T> void bind(Class<T> clazz, Class<? extends T> defaultImplementation) {

View File

@@ -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) {

View File

@@ -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<ScmPathInfoStore> 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);
}
}

View File

@@ -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<InitializationDto> 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() {

View File

@@ -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);
}

View File

@@ -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<User> 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<User> 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<User> 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<User> 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");
}
}

View File

@@ -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<JwtAccessToken> refreshedTokenResult = refresher.refresh(oldToken);
assertThat(refreshedTokenResult).isEmpty();
}
}