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:
Eduard Heimbuch
2020-08-03 17:41:40 +02:00
parent b926238e03
commit 4c9e96f7e2
21 changed files with 324 additions and 151 deletions

View File

@@ -29,6 +29,8 @@ import sonia.scm.SCMContext;
public class Authentications { public class Authentications {
private Authentications() {}
public static boolean isAuthenticatedSubjectAnonymous() { public static boolean isAuthenticatedSubjectAnonymous() {
return isSubjectAnonymous((String) SecurityUtils.getSubject().getPrincipal()); return isSubjectAnonymous((String) SecurityUtils.getSubject().getPrincipal());
} }

View File

@@ -59,16 +59,21 @@ import java.util.Set;
* @since 2.0.0 * @since 2.0.0
*/ */
@Singleton @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"; private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
/** Field description */ /**
* Field description
*/
private static final String HEADER_AUTHORIZATION = "Authorization"; private static final String HEADER_AUTHORIZATION = "Authorization";
/** the logger for AuthenticationFilter */ /**
* the logger for AuthenticationFilter
*/
private static final Logger logger = private static final Logger logger =
LoggerFactory.getLogger(AuthenticationFilter.class); LoggerFactory.getLogger(AuthenticationFilter.class);
@@ -95,38 +100,29 @@ public class AuthenticationFilter extends HttpFilter
* @param request servlet request * @param request servlet request
* @param response servlet response * @param response servlet response
* @param chain filter chain * @param chain filter chain
*
* @throws IOException * @throws IOException
* @throws ServletException * @throws ServletException
*/ */
@Override @Override
protected void doFilter(HttpServletRequest request, protected void doFilter(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) HttpServletResponse response, FilterChain chain)
throws IOException, ServletException throws IOException, ServletException {
{
Subject subject = SecurityUtils.getSubject(); Subject subject = SecurityUtils.getSubject();
AuthenticationToken token = createToken(request); AuthenticationToken token = createToken(request);
if (token != null) if (token != null) {
{
logger.trace( logger.trace(
"found authentication token on request, start authentication"); "found authentication token on request, start authentication");
handleAuthentication(request, response, chain, subject, token); handleAuthentication(request, response, chain, subject, token);
} } else if (subject.isAuthenticated()) {
else if (subject.isAuthenticated())
{
logger.trace("user is already authenticated"); logger.trace("user is already authenticated");
processChain(request, response, chain, subject); processChain(request, response, chain, subject);
} } else if (isAnonymousAccessEnabled()) {
else if (isAnonymousAccessEnabled() && !HttpUtil.isWUIRequest(request))
{
logger.trace("anonymous access granted"); logger.trace("anonymous access granted");
subject.login(new AnonymousToken()); subject.login(new AnonymousToken());
processChain(request, response, chain, subject); processChain(request, response, chain, subject);
} } else {
else
{
logger.trace("could not find user send unauthorized"); logger.trace("could not find user send unauthorized");
handleUnauthorized(request, response, chain); handleUnauthorized(request, response, chain);
} }
@@ -139,25 +135,19 @@ public class AuthenticationFilter extends HttpFilter
* @param request servlet request * @param request servlet request
* @param response servlet response * @param response servlet response
* @param chain filter chain * @param chain filter chain
*
* @throws IOException * @throws IOException
* @throws ServletException * @throws ServletException
*
* @since 1.8 * @since 1.8
*/ */
protected void handleUnauthorized(HttpServletRequest request, protected void handleUnauthorized(HttpServletRequest request,
HttpServletResponse response, FilterChain chain) HttpServletResponse response, FilterChain chain)
throws IOException, ServletException throws IOException, ServletException {
{
// send only forbidden, if the authentication has failed. // 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 // 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); sendFailedAuthenticationError(request, response);
} } else {
else
{
sendUnauthorizedError(request, response); sendUnauthorizedError(request, response);
} }
} }
@@ -165,16 +155,13 @@ public class AuthenticationFilter extends HttpFilter
/** /**
* Sends an error for a failed authentication back to client. * Sends an error for a failed authentication back to client.
* *
*
* @param request http request * @param request http request
* @param response http response * @param response http response
*
* @throws IOException * @throws IOException
*/ */
protected void sendFailedAuthenticationError(HttpServletRequest request, protected void sendFailedAuthenticationError(HttpServletRequest request,
HttpServletResponse response) HttpServletResponse response)
throws IOException throws IOException {
{
HttpUtil.sendUnauthorized(request, response, HttpUtil.sendUnauthorized(request, response,
configuration.getRealmDescription()); configuration.getRealmDescription());
} }
@@ -182,16 +169,13 @@ public class AuthenticationFilter extends HttpFilter
/** /**
* Sends an unauthorized error back to client. * Sends an unauthorized error back to client.
* *
*
* @param request http request * @param request http request
* @param response http response * @param response http response
*
* @throws IOException * @throws IOException
*/ */
protected void sendUnauthorizedError(HttpServletRequest request, protected void sendUnauthorizedError(HttpServletRequest request,
HttpServletResponse response) HttpServletResponse response)
throws IOException throws IOException {
{
HttpUtil.sendUnauthorized(request, response, HttpUtil.sendUnauthorized(request, response,
configuration.getRealmDescription()); configuration.getRealmDescription());
} }
@@ -200,20 +184,15 @@ public class AuthenticationFilter extends HttpFilter
* Iterates all {@link WebTokenGenerator} and creates an * Iterates all {@link WebTokenGenerator} and creates an
* {@link AuthenticationToken} from the given request. * {@link AuthenticationToken} from the given request.
* *
*
* @param request http servlet request * @param request http servlet request
*
* @return authentication token of {@code null} * @return authentication token of {@code null}
*/ */
private AuthenticationToken createToken(HttpServletRequest request) private AuthenticationToken createToken(HttpServletRequest request) {
{
AuthenticationToken token = null; AuthenticationToken token = null;
for (WebTokenGenerator generator : tokenGenerators) for (WebTokenGenerator generator : tokenGenerators) {
{
token = generator.createToken(request); token = generator.createToken(request);
if (token != null) if (token != null) {
{
logger.trace("generated web token {} from generator {}", logger.trace("generated web token {} from generator {}",
token.getClass(), generator.getClass()); token.getClass(), generator.getClass());
@@ -227,30 +206,24 @@ public class AuthenticationFilter extends HttpFilter
/** /**
* Handle authentication with the given {@link AuthenticationToken}. * Handle authentication with the given {@link AuthenticationToken}.
* *
*
* @param request http servlet request * @param request http servlet request
* @param response http servlet response * @param response http servlet response
* @param chain filter chain * @param chain filter chain
* @param subject subject * @param subject subject
* @param token authentication token * @param token authentication token
*
* @throws IOException * @throws IOException
* @throws ServletException * @throws ServletException
*/ */
private void handleAuthentication(HttpServletRequest request, private void handleAuthentication(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Subject subject, HttpServletResponse response, FilterChain chain, Subject subject,
AuthenticationToken token) AuthenticationToken token)
throws IOException, ServletException throws IOException, ServletException {
{
logger.trace("found basic authorization header, start authentication"); logger.trace("found basic authorization header, start authentication");
try try {
{
subject.login(token); subject.login(token);
processChain(request, response, chain, subject); processChain(request, response, chain, subject);
} } catch (AuthenticationException ex) {
catch (AuthenticationException ex)
{
logger.warn("authentication failed", ex); logger.warn("authentication failed", ex);
handleUnauthorized(request, response, chain); handleUnauthorized(request, response, chain);
} }
@@ -259,33 +232,26 @@ public class AuthenticationFilter extends HttpFilter
/** /**
* Process the filter chain. * Process the filter chain.
* *
*
* @param request http servlet request * @param request http servlet request
* @param response http servlet response * @param response http servlet response
* @param chain filter chain * @param chain filter chain
* @param subject subject * @param subject subject
*
* @throws IOException * @throws IOException
* @throws ServletException * @throws ServletException
*/ */
private void processChain(HttpServletRequest request, private void processChain(HttpServletRequest request,
HttpServletResponse response, FilterChain chain, Subject subject) HttpServletResponse response, FilterChain chain, Subject subject)
throws IOException, ServletException throws IOException, ServletException {
{
String username = Util.EMPTY_STRING; String username = Util.EMPTY_STRING;
if (!subject.isAuthenticated()) if (!subject.isAuthenticated()) {
{
// anonymous access // anonymous access
username = SCMContext.USER_ANONYMOUS; username = SCMContext.USER_ANONYMOUS;
} } else {
else
{
Object obj = subject.getPrincipal(); Object obj = subject.getPrincipal();
if (obj != null) if (obj != null) {
{
username = obj.toString(); username = obj.toString();
} }
} }
@@ -299,19 +265,21 @@ public class AuthenticationFilter extends HttpFilter
/** /**
* Returns {@code true} if anonymous access is enabled. * Returns {@code true} if anonymous access is enabled.
* *
*
* @return {@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; return (configuration != null) && configuration.getAnonymousMode() != AnonymousMode.OFF;
} }
//~--- fields --------------------------------------------------------------- //~--- fields ---------------------------------------------------------------
/** set of web token generators */ /**
* set of web token generators
*/
private final Set<WebTokenGenerator> tokenGenerators; private final Set<WebTokenGenerator> tokenGenerators;
/** scm main configuration */ /**
* scm main configuration
*/
protected ScmConfiguration configuration; protected ScmConfiguration configuration;
} }

View File

@@ -47,7 +47,7 @@
}, },
"dependencies": { "dependencies": {
"@scm-manager/ui-extensions": "^2.1.0", "@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", "classnames": "^2.2.6",
"date-fns": "^2.4.1", "date-fns": "^2.4.1",
"gitdiff-parser": "^0.1.2", "gitdiff-parser": "^0.1.2",

View File

@@ -89,14 +89,24 @@ const Footer: FC<Props> = ({ me, version, links }) => {
meSectionTile = <TitleWithIcon title={me.displayName} icon="user-circle" />; 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 ( return (
<footer className="footer"> <footer className="footer">
<section className="section container"> <section className="section container">
<div className="columns is-size-7"> <div className="columns is-size-7">
<FooterSection title={meSectionTile}> <FooterSection title={meSectionTile}>
<NavLink to="/me" label={t("footer.user.profile")} /> <NavLink to="/me" label={t("footer.user.profile")} />
<NavLink to="/me/settings/password" label={t("profile.changePasswordNavLink")} /> {meSectionBody}
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</FooterSection> </FooterSection>
<FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}> <FooterSection title={<TitleWithIcon title={t("footer.information.title")} icon="info-circle" />}>
<ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} /> <ExternalNavLink to="https://www.scm-manager.org/" label={`SCM-Manager ${version}`} />

View File

@@ -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 = () => { createNavigationItems = () => {
const navigationItems: ReactNode[] = []; const navigationItems: ReactNode[] = [];
const { t, links } = this.props; const { t, links } = this.props;
@@ -95,6 +112,7 @@ class PrimaryNavigation extends React.Component<Props> {
); );
this.appendLogout(navigationItems, append); this.appendLogout(navigationItems, append);
this.appendLogin(navigationItems, append);
return navigationItems; return navigationItems;
}; };

View File

@@ -25,7 +25,7 @@
"@scm-manager/tsconfig": "^2.1.0", "@scm-manager/tsconfig": "^2.1.0",
"@scm-manager/ui-scripts": "^2.1.0", "@scm-manager/ui-scripts": "^2.1.0",
"@scm-manager/ui-tests": "^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/classnames": "^2.2.9",
"@types/enzyme": "^3.10.3", "@types/enzyme": "^3.10.3",
"@types/fetch-mock": "^7.3.1", "@types/fetch-mock": "^7.3.1",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@scm-manager/ui-types", "name": "@scm-manager/ui-types",
"version": "2.3.0", "version": "2.4.0-SNAPSHOT",
"description": "Flow types for SCM-Manager related Objects", "description": "Flow types for SCM-Manager related Objects",
"main": "src/index.ts", "main": "src/index.ts",
"files": [ "files": [

View File

@@ -48,6 +48,7 @@
"repositories": "Repositories", "repositories": "Repositories",
"users": "Benutzer", "users": "Benutzer",
"logout": "Abmelden", "logout": "Abmelden",
"login": "Anmelden",
"groups": "Gruppen", "groups": "Gruppen",
"admin": "Administration" "admin": "Administration"
}, },

View File

@@ -49,6 +49,7 @@
"repositories": "Repositories", "repositories": "Repositories",
"users": "Users", "users": "Users",
"logout": "Logout", "logout": "Logout",
"login": "Login",
"groups": "Groups", "groups": "Groups",
"admin": "Administration" "admin": "Administration"
}, },

View File

@@ -26,7 +26,7 @@ import { connect } from "react-redux";
import { Redirect, withRouter } from "react-router-dom"; import { Redirect, withRouter } from "react-router-dom";
import { compose } from "redux"; import { compose } from "redux";
import styled from "styled-components"; 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 { getLoginInfoLink, getLoginLink } from "../modules/indexResource";
import LoginInfo from "../components/LoginInfo"; import LoginInfo from "../components/LoginInfo";
@@ -86,7 +86,7 @@ class Login extends React.Component<Props> {
} }
const mapStateToProps = (state: any) => { const mapStateToProps = (state: any) => {
const authenticated = isAuthenticated(state); const authenticated = state?.auth?.me && !isAnonymous(state.auth.me);
const loading = isLoginPending(state); const loading = isLoginPending(state);
const error = getLoginFailure(state); const error = getLoginFailure(state);
const link = getLoginLink(state); const link = getLoginLink(state);

View File

@@ -43,8 +43,10 @@ type Props = WithTranslation & {
class Logout extends React.Component<Props> { class Logout extends React.Component<Props> {
componentDidMount() { componentDidMount() {
if (this.props.logoutLink) {
this.props.logout(this.props.logoutLink); this.props.logout(this.props.logoutLink);
} }
}
render() { render() {
const { authenticated, redirecting, loading, error, t } = this.props; const { authenticated, redirecting, loading, error, t } = this.props;

View File

@@ -23,7 +23,7 @@
*/ */
import React from "react"; import React from "react";
import { Route, RouteComponentProps, withRouter } from "react-router-dom"; import { Route, RouteComponentProps, withRouter } from "react-router-dom";
import { getMe } from "../modules/auth"; import { getMe, isAnonymous } from "../modules/auth";
import { compose } from "redux"; import { compose } from "redux";
import { connect } from "react-redux"; import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
@@ -92,7 +92,9 @@ class Profile extends React.Component<Props> {
<CustomQueryFlexWrappedColumns> <CustomQueryFlexWrappedColumns>
<PrimaryContentColumn> <PrimaryContentColumn>
<Route path={url} exact render={() => <ProfileInfo me={me} />} /> <Route path={url} exact render={() => <ProfileInfo me={me} />} />
{me?._links?.password && (
<Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} /> <Route path={`${url}/settings/password`} render={() => <ChangeUserPassword me={me} />} />
)}
<ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} /> <ExtensionPoint name="profile.route" props={extensionProps} renderAll={true} />
</PrimaryContentColumn> </PrimaryContentColumn>
<SecondaryNavigationColumn> <SecondaryNavigationColumn>
@@ -103,6 +105,7 @@ class Profile extends React.Component<Props> {
label={t("profile.informationNavLink")} label={t("profile.informationNavLink")}
title={t("profile.informationNavLink")} title={t("profile.informationNavLink")}
/> />
{!isAnonymous(me) && (
<SubNavigation <SubNavigation
to={`${url}/settings/password`} to={`${url}/settings/password`}
label={t("profile.settingsNavLink")} label={t("profile.settingsNavLink")}
@@ -111,6 +114,7 @@ class Profile extends React.Component<Props> {
<NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} /> <NavLink to={`${url}/settings/password`} label={t("profile.changePasswordNavLink")} />
<ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} /> <ExtensionPoint name="profile.setting" props={extensionProps} renderAll={true} />
</SubNavigation> </SubNavigation>
)}
</SecondaryNavigation> </SecondaryNavigation>
</SecondaryNavigationColumn> </SecondaryNavigationColumn>
</CustomQueryFlexWrappedColumns> </CustomQueryFlexWrappedColumns>

View File

@@ -35,6 +35,7 @@ import reducer, {
getLoginFailure, getLoginFailure,
getLogoutFailure, getLogoutFailure,
getMe, getMe,
isAnonymous,
isAuthenticated, isAuthenticated,
isFetchMePending, isFetchMePending,
isLoginPending, isLoginPending,
@@ -117,7 +118,7 @@ describe("auth actions", () => {
fetchMock.postOnce("/api/v2/auth/access_token", { fetchMock.postOnce("/api/v2/auth/access_token", {
body: { body: {
cookie: true, cookie: true,
grant_type: "password", grantType: "password",
username: "tricia", username: "tricia",
password: "secret123" password: "secret123"
}, },
@@ -349,23 +350,6 @@ describe("auth selectors", () => {
).toBe(true); ).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", () => { it("should return me", () => {
expect( expect(
getMe({ getMe({
@@ -500,3 +484,8 @@ describe("auth selectors", () => {
).toBe(true); ).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();
});

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import { Me } from "@scm-manager/ui-types"; import { Link, Me } from "@scm-manager/ui-types";
import * as types from "./types"; import * as types from "./types";
import { apiClient, UnauthorizedError } from "@scm-manager/ui-components"; import { apiClient, UnauthorizedError } from "@scm-manager/ui-components";
@@ -32,9 +32,9 @@ import {
callFetchIndexResources, callFetchIndexResources,
fetchIndexResources, fetchIndexResources,
fetchIndexResourcesPending, fetchIndexResourcesPending,
fetchIndexResourcesSuccess, fetchIndexResourcesSuccess, getLoginLink
getLoginLink
} from "./indexResource"; } from "./indexResource";
import { AnyAction } from "redux";
// Action // Action
@@ -61,7 +61,7 @@ const initialState = {};
export default function reducer( export default function reducer(
state: object = initialState, state: object = initialState,
action: object = { action: AnyAction = {
type: "UNKNOWN" type: "UNKNOWN"
} }
) { ) {
@@ -174,23 +174,23 @@ const callFetchMe = (link: string): Promise<Me> => {
}; };
export const login = (loginLink: string, username: string, password: string) => { export const login = (loginLink: string, username: string, password: string) => {
const login_data = { const loginData = {
cookie: true, cookie: true,
grant_type: "password", grantType: "password",
username, username,
password password
}; };
return function(dispatch: any) { return function(dispatch: any) {
dispatch(loginPending()); dispatch(loginPending());
return apiClient return apiClient
.post(loginLink, login_data) .post(loginLink, loginData)
.then(() => { .then(() => {
dispatch(fetchIndexResourcesPending()); dispatch(fetchIndexResourcesPending());
return callFetchIndexResources(); return callFetchIndexResources();
}) })
.then(response => { .then(response => {
dispatch(fetchIndexResourcesSuccess(response)); dispatch(fetchIndexResourcesSuccess(response));
const meLink = response._links.me.href; const meLink = (response._links.me as Link).href;
return callFetchMe(meLink); return callFetchMe(meLink);
}) })
.then(me => { .then(me => {
@@ -256,17 +256,17 @@ export const logout = (link: string) => {
// selectors // selectors
const stateAuth = (state: object): object => { const stateAuth = (state: object): object => {
// @ts-ignore Right types for redux not available
return state.auth || {}; return state.auth || {};
}; };
export const isAuthenticated = (state: object) => { export const isAuthenticated = (state: object) => {
if (state.auth.me && !getLoginLink(state)) { // @ts-ignore Right types for redux not available
return true; return !!((state.auth.me && !getLoginLink(state)) || isAnonymous(state.auth.me));
}
return false;
}; };
export const getMe = (state: object): Me => { export const getMe = (state: object): Me => {
// @ts-ignore Right types for redux not available
return stateAuth(state).me; return stateAuth(state).me;
}; };
@@ -295,5 +295,12 @@ export const getLogoutFailure = (state: object) => {
}; };
export const isRedirecting = (state: object) => { export const isRedirecting = (state: object) => {
// @ts-ignore Right types for redux not available
return !!stateAuth(state).redirecting; return !!stateAuth(state).redirecting;
}; };
// Helper methods
export const isAnonymous = (me: Me) => {
return me?.name === "_anonymous";
};

View File

@@ -40,6 +40,7 @@ function removeAllEntriesOfIdentifierFromState(state: object, payload: any, iden
const newState = {}; const newState = {};
for (const failureType in state) { for (const failureType in state) {
if (failureType !== identifier && !failureType.startsWith(identifier)) { if (failureType !== identifier && !failureType.startsWith(identifier)) {
// @ts-ignore Right types not available
newState[failureType] = state[failureType]; newState[failureType] = state[failureType];
} }
} }
@@ -50,6 +51,7 @@ function removeFromState(state: object, identifier: string) {
const newState = {}; const newState = {};
for (const failureType in state) { for (const failureType in state) {
if (failureType !== identifier) { if (failureType !== identifier) {
// @ts-ignore Right types not available
newState[failureType] = state[failureType]; newState[failureType] = state[failureType];
} }
} }
@@ -90,11 +92,13 @@ export default function reducer(
} }
export function getFailure(state: object, actionType: string, itemId?: string | number) { export function getFailure(state: object, actionType: string, itemId?: string | number) {
// @ts-ignore Right types not available
if (state.failure) { if (state.failure) {
let identifier = actionType; let identifier = actionType;
if (itemId) { if (itemId) {
identifier += "/" + itemId; identifier += "/" + itemId;
} }
// @ts-ignore Right types not available
return state.failure[identifier]; return state.failure[identifier];
} }
} }

View File

@@ -111,23 +111,29 @@ export function getFetchIndexResourcesFailure(state: object) {
} }
export function getLinks(state: object) { export function getLinks(state: object) {
// @ts-ignore Right types not available
return state.indexResources.links; return state.indexResources.links;
} }
export function getLink(state: object, name: string) { export function getLink(state: object, name: string) {
// @ts-ignore Right types not available
if (state.indexResources.links && state.indexResources.links[name]) { if (state.indexResources.links && state.indexResources.links[name]) {
// @ts-ignore Right types not available
return state.indexResources.links[name].href; return state.indexResources.links[name].href;
} }
} }
export function getLinkCollection(state: object, name: string): Link[] { export function getLinkCollection(state: object, name: string): Link[] {
// @ts-ignore Right types not available
if (state.indexResources.links && state.indexResources.links[name]) { if (state.indexResources.links && state.indexResources.links[name]) {
// @ts-ignore Right types not available
return state.indexResources.links[name]; return state.indexResources.links[name];
} }
return []; return [];
} }
export function getAppVersion(state: object) { export function getAppVersion(state: object) {
// @ts-ignore Right types not available
return state.indexResources.version; return state.indexResources.version;
} }

View File

@@ -32,6 +32,7 @@ function removeFromState(state: object, identifier: string) {
const newState = {}; const newState = {};
for (const childType in state) { for (const childType in state) {
if (childType !== identifier) { if (childType !== identifier) {
// @ts-ignore Right types not available
newState[childType] = state[childType]; newState[childType] = state[childType];
} }
} }
@@ -42,6 +43,7 @@ function removeAllEntriesOfIdentifierFromState(state: object, payload: any, iden
const newState = {}; const newState = {};
for (const childType in state) { for (const childType in state) {
if (childType !== identifier && !childType.startsWith(identifier)) { if (childType !== identifier && !childType.startsWith(identifier)) {
// @ts-ignore Right types not available
newState[childType] = state[childType]; newState[childType] = state[childType];
} }
} }
@@ -92,6 +94,7 @@ export function isPending(state: object, actionType: string, itemId?: string | n
if (itemId) { if (itemId) {
type += "/" + itemId; type += "/" + itemId;
} }
// @ts-ignore Right types not available
if (state.pending && state.pending[type]) { if (state.pending && state.pending[type]) {
return true; return true;
} }

View File

@@ -35,6 +35,7 @@ import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration; import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupPermissions; import sonia.scm.group.GroupPermissions;
import sonia.scm.plugin.PluginPermissions; import sonia.scm.plugin.PluginPermissions;
import sonia.scm.security.AnonymousMode;
import sonia.scm.security.Authentications; import sonia.scm.security.Authentications;
import sonia.scm.security.PermissionPermissions; import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.UserPermissions; import sonia.scm.user.UserPermissions;
@@ -70,7 +71,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("loginInfo", loginInfoUrl)); builder.single(link("loginInfo", loginInfoUrl));
} }
if (SecurityUtils.getSubject().isAuthenticated()) { if (SecurityUtils.getSubject().isAuthenticated() && !Authentications.isAuthenticatedSubjectAnonymous() || isAnonymousAccess()) {
builder.single(link("me", resourceLinks.me().self())); builder.single(link("me", resourceLinks.me().self()));
if (Authentications.isAuthenticatedSubjectAnonymous()) { if (Authentications.isAuthenticatedSubjectAnonymous()) {
@@ -120,4 +121,8 @@ public class IndexDtoGenerator extends HalAppenderMapper {
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion()); return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion());
} }
private boolean isAnonymousAccess() {
return Authentications.isAuthenticatedSubjectAnonymous() && configuration.getAnonymousMode() == AnonymousMode.FULL;
}
} }

View File

@@ -83,15 +83,17 @@ public class MeDtoFactory extends HalAppenderMapper {
private MeDto createDto(User user) { private MeDto createDto(User user) {
Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self()); Links.Builder linksBuilder = linkingTo().self(resourceLinks.me().self());
if (isNotAnonymous(user)) {
if (UserPermissions.delete(user).isPermitted()) { if (UserPermissions.delete(user).isPermitted()) {
linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName()))); linksBuilder.single(link("delete", resourceLinks.me().delete(user.getName())));
} }
if (UserPermissions.modify(user).isPermitted()) { if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.me().update(user.getName()))); 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())); linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
} }
}
Embedded.Builder embeddedBuilder = embeddedBuilder(); Embedded.Builder embeddedBuilder = embeddedBuilder();
applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user); applyEnrichers(new EdisonHalAppender(linksBuilder, embeddedBuilder), new Me(), user);
@@ -99,4 +101,7 @@ public class MeDtoFactory extends HalAppenderMapper {
return new MeDto(linksBuilder.build(), embeddedBuilder.build()); return new MeDto(linksBuilder.build(), embeddedBuilder.build());
} }
private boolean isNotAnonymous(User user) {
return !Authentications.isSubjectAnonymous(user.getName());
}
} }

View File

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

View File

@@ -198,6 +198,19 @@ class MeDtoFactoryTest {
assertThat(dto.getLinks().getLinkBy("password")).isNotPresent(); 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 @Test
void shouldAppendLinks() { void shouldAppendLinks() {
prepareSubject(UserTestData.createTrillian()); prepareSubject(UserTestData.createTrillian());
@@ -213,6 +226,4 @@ class MeDtoFactoryTest {
MeDto dto = meDtoFactory.create(); MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("profile").get().getHref()).isEqualTo("http://hitchhiker.com/users/trillian"); assertThat(dto.getLinks().getLinkBy("profile").get().getHref()).isEqualTo("http://hitchhiker.com/users/trillian");
} }
} }