mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-09 06:55:47 +01:00
add anonymous mode for webclient / change footer and redirects if user is anonymous / add login button if user is anonymous
This commit is contained in:
@@ -29,6 +29,8 @@ import sonia.scm.SCMContext;
|
||||
|
||||
public class Authentications {
|
||||
|
||||
private Authentications() {}
|
||||
|
||||
public static boolean isAuthenticatedSubjectAnonymous() {
|
||||
return isSubjectAnonymous((String) SecurityUtils.getSubject().getPrincipal());
|
||||
}
|
||||
|
||||
@@ -59,16 +59,21 @@ import java.util.Set;
|
||||
* @since 2.0.0
|
||||
*/
|
||||
@Singleton
|
||||
public class AuthenticationFilter extends HttpFilter
|
||||
{
|
||||
public class AuthenticationFilter extends HttpFilter {
|
||||
|
||||
/** marker for failed authentication */
|
||||
/**
|
||||
* marker for failed authentication
|
||||
*/
|
||||
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
|
||||
|
||||
/** Field description */
|
||||
/**
|
||||
* Field description
|
||||
*/
|
||||
private static final String HEADER_AUTHORIZATION = "Authorization";
|
||||
|
||||
/** the logger for AuthenticationFilter */
|
||||
/**
|
||||
* the logger for AuthenticationFilter
|
||||
*/
|
||||
private static final Logger logger =
|
||||
LoggerFactory.getLogger(AuthenticationFilter.class);
|
||||
|
||||
@@ -95,38 +100,29 @@ public class AuthenticationFilter extends HttpFilter
|
||||
* @param request servlet request
|
||||
* @param response servlet response
|
||||
* @param chain filter chain
|
||||
*
|
||||
* @throws IOException
|
||||
* @throws ServletException
|
||||
*/
|
||||
@Override
|
||||
protected void doFilter(HttpServletRequest request,
|
||||
HttpServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException
|
||||
{
|
||||
throws IOException, ServletException {
|
||||
Subject subject = SecurityUtils.getSubject();
|
||||
|
||||
AuthenticationToken token = createToken(request);
|
||||
|
||||
if (token != null)
|
||||
{
|
||||
if (token != null) {
|
||||
logger.trace(
|
||||
"found authentication token on request, start authentication");
|
||||
handleAuthentication(request, response, chain, subject, token);
|
||||
}
|
||||
else if (subject.isAuthenticated())
|
||||
{
|
||||
} else if (subject.isAuthenticated()) {
|
||||
logger.trace("user is already authenticated");
|
||||
processChain(request, response, chain, subject);
|
||||
}
|
||||
else if (isAnonymousAccessEnabled() && !HttpUtil.isWUIRequest(request))
|
||||
{
|
||||
} else if (isAnonymousAccessEnabled()) {
|
||||
logger.trace("anonymous access granted");
|
||||
subject.login(new AnonymousToken());
|
||||
processChain(request, response, chain, subject);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
logger.trace("could not find user send unauthorized");
|
||||
handleUnauthorized(request, response, chain);
|
||||
}
|
||||
@@ -139,25 +135,19 @@ public class AuthenticationFilter extends HttpFilter
|
||||
* @param request servlet request
|
||||
* @param response servlet response
|
||||
* @param chain filter chain
|
||||
*
|
||||
* @throws IOException
|
||||
* @throws ServletException
|
||||
*
|
||||
* @since 1.8
|
||||
*/
|
||||
protected void handleUnauthorized(HttpServletRequest request,
|
||||
HttpServletResponse response, FilterChain chain)
|
||||
throws IOException, ServletException
|
||||
{
|
||||
throws IOException, ServletException {
|
||||
|
||||
// send only forbidden, if the authentication has failed.
|
||||
// see https://bitbucket.org/sdorra/scm-manager/issue/545/git-clone-with-username-in-url-does-not
|
||||
if (Boolean.TRUE.equals(request.getAttribute(ATTRIBUTE_FAILED_AUTH)))
|
||||
{
|
||||
if (Boolean.TRUE.equals(request.getAttribute(ATTRIBUTE_FAILED_AUTH))) {
|
||||
sendFailedAuthenticationError(request, response);
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
sendUnauthorizedError(request, response);
|
||||
}
|
||||
}
|
||||
@@ -165,16 +155,13 @@ public class AuthenticationFilter extends HttpFilter
|
||||
/**
|
||||
* Sends an error for a failed authentication back to client.
|
||||
*
|
||||
*
|
||||
* @param request http request
|
||||
* @param response http response
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
protected void sendFailedAuthenticationError(HttpServletRequest request,
|
||||
HttpServletResponse response)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
HttpUtil.sendUnauthorized(request, response,
|
||||
configuration.getRealmDescription());
|
||||
}
|
||||
@@ -182,16 +169,13 @@ public class AuthenticationFilter extends HttpFilter
|
||||
/**
|
||||
* Sends an unauthorized error back to client.
|
||||
*
|
||||
*
|
||||
* @param request http request
|
||||
* @param response http response
|
||||
*
|
||||
* @throws IOException
|
||||
*/
|
||||
protected void sendUnauthorizedError(HttpServletRequest request,
|
||||
HttpServletResponse response)
|
||||
throws IOException
|
||||
{
|
||||
throws IOException {
|
||||
HttpUtil.sendUnauthorized(request, response,
|
||||
configuration.getRealmDescription());
|
||||
}
|
||||
@@ -200,20 +184,15 @@ public class AuthenticationFilter extends HttpFilter
|
||||
* Iterates all {@link WebTokenGenerator} and creates an
|
||||
* {@link AuthenticationToken} from the given request.
|
||||
*
|
||||
*
|
||||
* @param request http servlet request
|
||||
*
|
||||
* @return authentication token of {@code null}
|
||||
*/
|
||||
private AuthenticationToken createToken(HttpServletRequest request)
|
||||
{
|
||||
private AuthenticationToken createToken(HttpServletRequest request) {
|
||||
AuthenticationToken token = null;
|
||||
for (WebTokenGenerator generator : tokenGenerators)
|
||||
{
|
||||
for (WebTokenGenerator generator : tokenGenerators) {
|
||||
token = generator.createToken(request);
|
||||
|
||||
if (token != null)
|
||||
{
|
||||
if (token != null) {
|
||||
logger.trace("generated web token {} from generator {}",
|
||||
token.getClass(), generator.getClass());
|
||||
|
||||
@@ -227,30 +206,24 @@ public class AuthenticationFilter extends HttpFilter
|
||||
/**
|
||||
* Handle authentication with the given {@link AuthenticationToken}.
|
||||
*
|
||||
*
|
||||
* @param request http servlet request
|
||||
* @param response http servlet response
|
||||
* @param chain filter chain
|
||||
* @param subject subject
|
||||
* @param token authentication token
|
||||
*
|
||||
* @throws IOException
|
||||
* @throws ServletException
|
||||
*/
|
||||
private void handleAuthentication(HttpServletRequest request,
|
||||
HttpServletResponse response, FilterChain chain, Subject subject,
|
||||
AuthenticationToken token)
|
||||
throws IOException, ServletException
|
||||
{
|
||||
throws IOException, ServletException {
|
||||
logger.trace("found basic authorization header, start authentication");
|
||||
|
||||
try
|
||||
{
|
||||
try {
|
||||
subject.login(token);
|
||||
processChain(request, response, chain, subject);
|
||||
}
|
||||
catch (AuthenticationException ex)
|
||||
{
|
||||
} catch (AuthenticationException ex) {
|
||||
logger.warn("authentication failed", ex);
|
||||
handleUnauthorized(request, response, chain);
|
||||
}
|
||||
@@ -259,33 +232,26 @@ public class AuthenticationFilter extends HttpFilter
|
||||
/**
|
||||
* Process the filter chain.
|
||||
*
|
||||
*
|
||||
* @param request http servlet request
|
||||
* @param response http servlet response
|
||||
* @param chain filter chain
|
||||
* @param subject subject
|
||||
*
|
||||
* @throws IOException
|
||||
* @throws ServletException
|
||||
*/
|
||||
private void processChain(HttpServletRequest request,
|
||||
HttpServletResponse response, FilterChain chain, Subject subject)
|
||||
throws IOException, ServletException
|
||||
{
|
||||
throws IOException, ServletException {
|
||||
String username = Util.EMPTY_STRING;
|
||||
|
||||
if (!subject.isAuthenticated())
|
||||
{
|
||||
if (!subject.isAuthenticated()) {
|
||||
|
||||
// anonymous access
|
||||
username = SCMContext.USER_ANONYMOUS;
|
||||
}
|
||||
else
|
||||
{
|
||||
} else {
|
||||
Object obj = subject.getPrincipal();
|
||||
|
||||
if (obj != null)
|
||||
{
|
||||
if (obj != null) {
|
||||
username = obj.toString();
|
||||
}
|
||||
}
|
||||
@@ -299,19 +265,21 @@ public class AuthenticationFilter extends HttpFilter
|
||||
/**
|
||||
* Returns {@code true} if anonymous access is enabled.
|
||||
*
|
||||
*
|
||||
* @return {@code true} if anonymous access is enabled
|
||||
*/
|
||||
private boolean isAnonymousAccessEnabled()
|
||||
{
|
||||
private boolean isAnonymousAccessEnabled() {
|
||||
return (configuration != null) && configuration.getAnonymousMode() != AnonymousMode.OFF;
|
||||
}
|
||||
|
||||
//~--- fields ---------------------------------------------------------------
|
||||
|
||||
/** set of web token generators */
|
||||
/**
|
||||
* set of web token generators
|
||||
*/
|
||||
private final Set<WebTokenGenerator> tokenGenerators;
|
||||
|
||||
/** scm main configuration */
|
||||
/**
|
||||
* scm main configuration
|
||||
*/
|
||||
protected ScmConfiguration configuration;
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-extensions": "^2.1.0",
|
||||
"@scm-manager/ui-types": "^2.3.0",
|
||||
"@scm-manager/ui-types": "^2.4.0-SNAPSHOT",
|
||||
"classnames": "^2.2.6",
|
||||
"date-fns": "^2.4.1",
|
||||
"gitdiff-parser": "^0.1.2",
|
||||
|
||||
@@ -89,14 +89,24 @@ const Footer: FC<Props> = ({ me, version, links }) => {
|
||||
meSectionTile = <TitleWithIcon title={me.displayName} icon="user-circle" />;
|
||||
}
|
||||
|
||||
let meSectionBody = <div />;
|
||||
{
|
||||
if (me.name !== "_anonymous")
|
||||
meSectionBody = (
|
||||
<>
|
||||
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="footer">
|
||||
<section className="section container">
|
||||
<div className="columns is-size-7">
|
||||
<FooterSection title={meSectionTile}>
|
||||
<NavLink to="/me" label={t("footer.user.profile")} />
|
||||
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} />
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
{meSectionBody}
|
||||
</FooterSection>
|
||||
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
|
||||
<ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />
|
||||
|
||||
@@ -63,6 +63,23 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
}
|
||||
};
|
||||
|
||||
appendLogin = (navigationItems: ReactNode[], append: Appender) => {
|
||||
const { t, links } = this.props;
|
||||
|
||||
const props = {
|
||||
links,
|
||||
label: t("primary-navigation.login")
|
||||
};
|
||||
|
||||
if (binder.hasExtension("primary-navigation.login", props)) {
|
||||
navigationItems.push(
|
||||
<ExtensionPoint key="primary-navigation.login" name="primary-navigation.login" props={props} />
|
||||
);
|
||||
} else {
|
||||
append("/login", "/login", "primary-navigation.login", "login");
|
||||
}
|
||||
};
|
||||
|
||||
createNavigationItems = () => {
|
||||
const navigationItems: ReactNode[] = [];
|
||||
const { t, links } = this.props;
|
||||
@@ -95,6 +112,7 @@ class PrimaryNavigation extends React.Component<Props> {
|
||||
);
|
||||
|
||||
this.appendLogout(navigationItems, append);
|
||||
this.appendLogin(navigationItems, append);
|
||||
|
||||
return navigationItems;
|
||||
};
|
||||
|
||||
@@ -25,7 +25,7 @@
|
||||
"@scm-manager/tsconfig": "^2.1.0",
|
||||
"@scm-manager/ui-scripts": "^2.1.0",
|
||||
"@scm-manager/ui-tests": "^2.1.0",
|
||||
"@scm-manager/ui-types": "^2.3.0",
|
||||
"@scm-manager/ui-types": "^2.4.0-SNAPSHOT",
|
||||
"@types/classnames": "^2.2.9",
|
||||
"@types/enzyme": "^3.10.3",
|
||||
"@types/fetch-mock": "^7.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-types",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"description": "Flow types for SCM-Manager related Objects",
|
||||
"main": "src/index.ts",
|
||||
"files": [
|
||||
|
||||
@@ -48,6 +48,7 @@
|
||||
"repositories": "Repositories",
|
||||
"users": "Benutzer",
|
||||
"logout": "Abmelden",
|
||||
"login": "Anmelden",
|
||||
"groups": "Gruppen",
|
||||
"admin": "Administration"
|
||||
},
|
||||
|
||||
@@ -49,6 +49,7 @@
|
||||
"repositories": "Repositories",
|
||||
"users": "Users",
|
||||
"logout": "Logout",
|
||||
"login": "Login",
|
||||
"groups": "Groups",
|
||||
"admin": "Administration"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ import { connect } from "react-redux";
|
||||
import { Redirect, withRouter } from "react-router-dom";
|
||||
import { compose } from "redux";
|
||||
import styled from "styled-components";
|
||||
import { getLoginFailure, isAuthenticated, isLoginPending, login } from "../modules/auth";
|
||||
import { getLoginFailure, isAnonymous, isLoginPending, login } from "../modules/auth";
|
||||
import { getLoginInfoLink, getLoginLink } from "../modules/indexResource";
|
||||
import LoginInfo from "../components/LoginInfo";
|
||||
|
||||
@@ -86,7 +86,7 @@ class Login extends React.Component<Props> {
|
||||
}
|
||||
|
||||
const mapStateToProps = (state: any) => {
|
||||
const authenticated = isAuthenticated(state);
|
||||
const authenticated = state?.auth?.me && !isAnonymous(state.auth.me);
|
||||
const loading = isLoginPending(state);
|
||||
const error = getLoginFailure(state);
|
||||
const link = getLoginLink(state);
|
||||
|
||||
@@ -43,8 +43,10 @@ type Props = WithTranslation & {
|
||||
|
||||
class Logout extends React.Component<Props> {
|
||||
componentDidMount() {
|
||||
if (this.props.logoutLink) {
|
||||
this.props.logout(this.props.logoutLink);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { authenticated, redirecting, loading, error, t } = this.props;
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
*/
|
||||
import React from "react";
|
||||
import { Route, RouteComponentProps, withRouter } from "react-router-dom";
|
||||
import { getMe } from "../modules/auth";
|
||||
import { getMe, isAnonymous } from "../modules/auth";
|
||||
import { compose } from "redux";
|
||||
import { connect } from "react-redux";
|
||||
import { WithTranslation, withTranslation } from "react-i18next";
|
||||
@@ -92,7 +92,9 @@ class Profile extends React.Component<Props> {
|
||||
<CustomQueryFlexWrappedColumns>
|
||||
<PrimaryContentColumn>
|
||||
<Route path={url} exact render={() => <ProfileInfo me={me} />} />
|
||||
{me?._links?.password && (
|
||||
<Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} />
|
||||
)}
|
||||
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
|
||||
</PrimaryContentColumn>
|
||||
<SecondaryNavigationColumn>
|
||||
@@ -103,6 +105,7 @@ class Profile extends React.Component<Props> {
|
||||
label={t("profile.informationNavLink")}
|
||||
title={t("profile.informationNavLink")}
|
||||
/>
|
||||
{!isAnonymous(me) && (
|
||||
<SubNavigation
|
||||
to={`${url}/settings/password`}
|
||||
label={t("profile.settingsNavLink")}
|
||||
@@ -111,6 +114,7 @@ class Profile extends React.Component<Props> {
|
||||
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
|
||||
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
|
||||
</SubNavigation>
|
||||
)}
|
||||
</SecondaryNavigation>
|
||||
</SecondaryNavigationColumn>
|
||||
</CustomQueryFlexWrappedColumns>
|
||||
|
||||
@@ -35,6 +35,7 @@ import reducer, {
|
||||
getLoginFailure,
|
||||
getLogoutFailure,
|
||||
getMe,
|
||||
isAnonymous,
|
||||
isAuthenticated,
|
||||
isFetchMePending,
|
||||
isLoginPending,
|
||||
@@ -117,7 +118,7 @@ describe("auth actions", () => {
|
||||
fetchMock.postOnce("/api/v2/auth/access_token", {
|
||||
body: {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
grantType: "password",
|
||||
username: "tricia",
|
||||
password: "secret123"
|
||||
},
|
||||
@@ -349,23 +350,6 @@ describe("auth selectors", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if me exist and login Link does exist", () => {
|
||||
expect(
|
||||
isAuthenticated({
|
||||
auth: {
|
||||
me
|
||||
},
|
||||
indexResources: {
|
||||
links: {
|
||||
login: {
|
||||
href: "login.href"
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("should return me", () => {
|
||||
expect(
|
||||
getMe({
|
||||
@@ -500,3 +484,8 @@ describe("auth selectors", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should check if current user is anonymous", () => {
|
||||
expect(isAnonymous({ name: "_anonymous", displayName: "Anon", _links: [], groups: [], mail: "" })).toBeTruthy();
|
||||
expect(isAnonymous({ name: "scmadmin", displayName: "SCM Admin", _links: [], groups: [], mail: "" })).toBeFalsy();
|
||||
});
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { Me } from "@scm-manager/ui-types";
|
||||
import { Link, Me } from "@scm-manager/ui-types";
|
||||
import * as types from "./types";
|
||||
|
||||
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
|
||||
@@ -32,9 +32,9 @@ import {
|
||||
callFetchIndexResources,
|
||||
fetchIndexResources,
|
||||
fetchIndexResourcesPending,
|
||||
fetchIndexResourcesSuccess,
|
||||
getLoginLink
|
||||
fetchIndexResourcesSuccess, getLoginLink
|
||||
} from "./indexResource";
|
||||
import { AnyAction } from "redux";
|
||||
|
||||
// Action
|
||||
|
||||
@@ -61,7 +61,7 @@ const initialState = {};
|
||||
|
||||
export default function reducer(
|
||||
state: object = initialState,
|
||||
action: object = {
|
||||
action: AnyAction = {
|
||||
type: "UNKNOWN"
|
||||
}
|
||||
) {
|
||||
@@ -174,23 +174,23 @@ const callFetchMe = (link: string): Promise<Me> => {
|
||||
};
|
||||
|
||||
export const login = (loginLink: string, username: string, password: string) => {
|
||||
const login_data = {
|
||||
const loginData = {
|
||||
cookie: true,
|
||||
grant_type: "password",
|
||||
grantType: "password",
|
||||
username,
|
||||
password
|
||||
};
|
||||
return function(dispatch: any) {
|
||||
dispatch(loginPending());
|
||||
return apiClient
|
||||
.post(loginLink, login_data)
|
||||
.post(loginLink, loginData)
|
||||
.then(() => {
|
||||
dispatch(fetchIndexResourcesPending());
|
||||
return callFetchIndexResources();
|
||||
})
|
||||
.then(response => {
|
||||
dispatch(fetchIndexResourcesSuccess(response));
|
||||
const meLink = response._links.me.href;
|
||||
const meLink = (response._links.me as Link).href;
|
||||
return callFetchMe(meLink);
|
||||
})
|
||||
.then(me => {
|
||||
@@ -256,17 +256,17 @@ export const logout = (link: string) => {
|
||||
// selectors
|
||||
|
||||
const stateAuth = (state: object): object => {
|
||||
// @ts-ignore Right types for redux not available
|
||||
return state.auth || {};
|
||||
};
|
||||
|
||||
export const isAuthenticated = (state: object) => {
|
||||
if (state.auth.me && !getLoginLink(state)) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// @ts-ignore Right types for redux not available
|
||||
return !!((state.auth.me && !getLoginLink(state)) || isAnonymous(state.auth.me));
|
||||
};
|
||||
|
||||
export const getMe = (state: object): Me => {
|
||||
// @ts-ignore Right types for redux not available
|
||||
return stateAuth(state).me;
|
||||
};
|
||||
|
||||
@@ -295,5 +295,12 @@ export const getLogoutFailure = (state: object) => {
|
||||
};
|
||||
|
||||
export const isRedirecting = (state: object) => {
|
||||
// @ts-ignore Right types for redux not available
|
||||
return !!stateAuth(state).redirecting;
|
||||
};
|
||||
|
||||
// Helper methods
|
||||
|
||||
export const isAnonymous = (me: Me) => {
|
||||
return me?.name === "_anonymous";
|
||||
};
|
||||
|
||||
@@ -40,6 +40,7 @@ function removeAllEntriesOfIdentifierFromState(state: object, payload: any, iden
|
||||
const newState = {};
|
||||
for (const failureType in state) {
|
||||
if (failureType !== identifier && !failureType.startsWith(identifier)) {
|
||||
// @ts-ignore Right types not available
|
||||
newState[failureType] = state[failureType];
|
||||
}
|
||||
}
|
||||
@@ -50,6 +51,7 @@ function removeFromState(state: object, identifier: string) {
|
||||
const newState = {};
|
||||
for (const failureType in state) {
|
||||
if (failureType !== identifier) {
|
||||
// @ts-ignore Right types not available
|
||||
newState[failureType] = state[failureType];
|
||||
}
|
||||
}
|
||||
@@ -90,11 +92,13 @@ export default function reducer(
|
||||
}
|
||||
|
||||
export function getFailure(state: object, actionType: string, itemId?: string | number) {
|
||||
// @ts-ignore Right types not available
|
||||
if (state.failure) {
|
||||
let identifier = actionType;
|
||||
if (itemId) {
|
||||
identifier += "/" + itemId;
|
||||
}
|
||||
// @ts-ignore Right types not available
|
||||
return state.failure[identifier];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,23 +111,29 @@ export function getFetchIndexResourcesFailure(state: object) {
|
||||
}
|
||||
|
||||
export function getLinks(state: object) {
|
||||
// @ts-ignore Right types not available
|
||||
return state.indexResources.links;
|
||||
}
|
||||
|
||||
export function getLink(state: object, name: string) {
|
||||
// @ts-ignore Right types not available
|
||||
if (state.indexResources.links && state.indexResources.links[name]) {
|
||||
// @ts-ignore Right types not available
|
||||
return state.indexResources.links[name].href;
|
||||
}
|
||||
}
|
||||
|
||||
export function getLinkCollection(state: object, name: string): Link[] {
|
||||
// @ts-ignore Right types not available
|
||||
if (state.indexResources.links && state.indexResources.links[name]) {
|
||||
// @ts-ignore Right types not available
|
||||
return state.indexResources.links[name];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
export function getAppVersion(state: object) {
|
||||
// @ts-ignore Right types not available
|
||||
return state.indexResources.version;
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ function removeFromState(state: object, identifier: string) {
|
||||
const newState = {};
|
||||
for (const childType in state) {
|
||||
if (childType !== identifier) {
|
||||
// @ts-ignore Right types not available
|
||||
newState[childType] = state[childType];
|
||||
}
|
||||
}
|
||||
@@ -42,6 +43,7 @@ function removeAllEntriesOfIdentifierFromState(state: object, payload: any, iden
|
||||
const newState = {};
|
||||
for (const childType in state) {
|
||||
if (childType !== identifier && !childType.startsWith(identifier)) {
|
||||
// @ts-ignore Right types not available
|
||||
newState[childType] = state[childType];
|
||||
}
|
||||
}
|
||||
@@ -92,6 +94,7 @@ export function isPending(state: object, actionType: string, itemId?: string | n
|
||||
if (itemId) {
|
||||
type += "/" + itemId;
|
||||
}
|
||||
// @ts-ignore Right types not available
|
||||
if (state.pending && state.pending[type]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,7 @@ import sonia.scm.config.ConfigurationPermissions;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.group.GroupPermissions;
|
||||
import sonia.scm.plugin.PluginPermissions;
|
||||
import sonia.scm.security.AnonymousMode;
|
||||
import sonia.scm.security.Authentications;
|
||||
import sonia.scm.security.PermissionPermissions;
|
||||
import sonia.scm.user.UserPermissions;
|
||||
@@ -70,7 +71,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
||||
builder.single(link("loginInfo", loginInfoUrl));
|
||||
}
|
||||
|
||||
if (SecurityUtils.getSubject().isAuthenticated()) {
|
||||
if (SecurityUtils.getSubject().isAuthenticated() && !Authentications.isAuthenticatedSubjectAnonymous() || isAnonymousAccess()) {
|
||||
builder.single(link("me", resourceLinks.me().self()));
|
||||
|
||||
if (Authentications.isAuthenticatedSubjectAnonymous()) {
|
||||
@@ -120,4 +121,8 @@ public class IndexDtoGenerator extends HalAppenderMapper {
|
||||
|
||||
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion());
|
||||
}
|
||||
|
||||
private boolean isAnonymousAccess() {
|
||||
return Authentications.isAuthenticatedSubjectAnonymous() && configuration.getAnonymousMode() == AnonymousMode.FULL;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -83,15 +83,17 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
|
||||
private MeDto createDto(User user) {
|
||||
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
|
||||
if (isNotAnonymous(user)) {
|
||||
if (UserPermissions.delete(user).isPermitted()) {
|
||||
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
|
||||
}
|
||||
if (UserPermissions.modify(user).isPermitted()) {
|
||||
linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
|
||||
}
|
||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted() && !Authentications.isSubjectAnonymous(user.getName())) {
|
||||
if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
|
||||
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
|
||||
}
|
||||
}
|
||||
|
||||
Embedded.Builder embeddedBuilder = embeddedBuilder();
|
||||
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
|
||||
@@ -99,4 +101,7 @@ public class MeDtoFactory extends HalAppenderMapper {
|
||||
return new MeDto(linksBuilder.build(), embeddedBuilder.build());
|
||||
}
|
||||
|
||||
private boolean isNotAnonymous(User user) {
|
||||
return !Authentications.isSubjectAnonymous(user.getName());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/*
|
||||
* 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.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.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.BasicContextProvider;
|
||||
import sonia.scm.config.ScmConfiguration;
|
||||
import sonia.scm.security.AnonymousMode;
|
||||
|
||||
import java.net.URI;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
import static sonia.scm.SCMContext.USER_ANONYMOUS;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class IndexDtoGeneratorTest {
|
||||
|
||||
private static final ScmPathInfo scmPathInfo = () -> URI.create("/api/v2");
|
||||
|
||||
@Mock
|
||||
private ScmConfiguration configuration;
|
||||
@Mock
|
||||
private BasicContextProvider contextProvider;
|
||||
@Mock
|
||||
private ResourceLinks resourceLinks;
|
||||
|
||||
@Mock
|
||||
private Subject subject;
|
||||
|
||||
@InjectMocks
|
||||
private IndexDtoGenerator generator;
|
||||
|
||||
@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();
|
||||
}
|
||||
|
||||
|
||||
private void mockResourceLinks() {
|
||||
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() {
|
||||
mockResourceLinks();
|
||||
when(resourceLinks.repositoryCollection()).thenReturn(new ResourceLinks.RepositoryCollectionLinks(scmPathInfo));
|
||||
when(resourceLinks.repositoryVerbs()).thenReturn(new ResourceLinks.RepositoryVerbLinks(scmPathInfo));
|
||||
when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(scmPathInfo));
|
||||
when(resourceLinks.repositoryRoleCollection()).thenReturn(new ResourceLinks.RepositoryRoleCollectionLinks(scmPathInfo));
|
||||
when(resourceLinks.namespaceStrategies()).thenReturn(new ResourceLinks.NamespaceStrategiesLinks(scmPathInfo));
|
||||
when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(scmPathInfo, new ResourceLinks.UserLinks(scmPathInfo)));
|
||||
}
|
||||
}
|
||||
@@ -198,6 +198,19 @@ class MeDtoFactoryTest {
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
}
|
||||
|
||||
|
||||
@Test
|
||||
void shouldAppendOnlySelfLinkIfAnonymousUser() {
|
||||
User user = SCMContext.ANONYMOUS;
|
||||
prepareSubject(user);
|
||||
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("self")).isPresent();
|
||||
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
|
||||
assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
|
||||
assertThat(dto.getLinks().getLinkBy("update")).isNotPresent();
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldAppendLinks() {
|
||||
prepareSubject(UserTestData.createTrillian());
|
||||
@@ -213,6 +226,4 @@ class MeDtoFactoryTest {
|
||||
MeDto dto = meDtoFactory.create();
|
||||
assertThat(dto.getLinks().getLinkBy("profile").get().getHref()).isEqualTo("http://hitchhiker.com/users/trillian");
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user