diff --git a/.hgignore b/.hgignore index 4e95a4bedc..d7152e7a57 100644 --- a/.hgignore +++ b/.hgignore @@ -35,3 +35,4 @@ scm-ui/yarn.lock scm-ui/.gitignore scm-ui/package-lock.json node_modules +scm-ui/.flowconfig diff --git a/pom.xml b/pom.xml index 2416371d48..7ed637f0d7 100644 --- a/pom.xml +++ b/pom.xml @@ -70,6 +70,7 @@ scm-test scm-plugins scm-dao-xml + scm-ui scm-webapp scm-server scm-clients diff --git a/scm-ui/flow-typed/npm/history_v4.x.x.js b/scm-ui/flow-typed/npm/history_v4.x.x.js new file mode 100644 index 0000000000..04061f029c --- /dev/null +++ b/scm-ui/flow-typed/npm/history_v4.x.x.js @@ -0,0 +1,128 @@ +// flow-typed signature: eb8bd974b677b08dfca89de9ac05b60b +// flow-typed version: 43b30482ac/history_v4.x.x/flow_>=v0.25.x + +declare module "history/createBrowserHistory" { + declare function Unblock(): void; + + declare export type Action = "PUSH" | "REPLACE" | "POP"; + + declare export type BrowserLocation = { + pathname: string, + search: string, + hash: string, + // Browser and Memory specific + state: string, + key: string, + }; + + declare export type BrowserHistory = { + length: number, + location: BrowserLocation, + action: Action, + push: (path: string, Array) => void, + replace: (path: string, Array) => void, + go: (n: number) => void, + goBack: () => void, + goForward: () => void, + listen: Function, + block: (message: string) => Unblock, + block: ((location: BrowserLocation, action: Action) => string) => Unblock, + push: (path: string) => void, + replace: (path: string) => void, + }; + + declare type HistoryOpts = { + basename?: string, + forceRefresh?: boolean, + getUserConfirmation?: ( + message: string, + callback: (willContinue: boolean) => void, + ) => void, + }; + + declare export default (opts?: HistoryOpts) => BrowserHistory; +} + +declare module "history/createMemoryHistory" { + declare function Unblock(): void; + + declare export type Action = "PUSH" | "REPLACE" | "POP"; + + declare export type MemoryLocation = { + pathname: string, + search: string, + hash: string, + // Browser and Memory specific + state: string, + key: string, + }; + + declare export type MemoryHistory = { + length: number, + location: MemoryLocation, + action: Action, + index: number, + entries: Array, + push: (path: string, Array) => void, + replace: (path: string, Array) => void, + go: (n: number) => void, + goBack: () => void, + goForward: () => void, + // Memory only + canGo: (n: number) => boolean, + listen: Function, + block: (message: string) => Unblock, + block: ((location: MemoryLocation, action: Action) => string) => Unblock, + push: (path: string) => void, + }; + + declare type HistoryOpts = { + initialEntries?: Array, + initialIndex?: number, + keyLength?: number, + getUserConfirmation?: ( + message: string, + callback: (willContinue: boolean) => void, + ) => void, + }; + + declare export default (opts?: HistoryOpts) => MemoryHistory; +} + +declare module "history/createHashHistory" { + declare function Unblock(): void; + + declare export type Action = "PUSH" | "REPLACE" | "POP"; + + declare export type HashLocation = { + pathname: string, + search: string, + hash: string, + }; + + declare export type HashHistory = { + length: number, + location: HashLocation, + action: Action, + push: (path: string, Array) => void, + replace: (path: string, Array) => void, + go: (n: number) => void, + goBack: () => void, + goForward: () => void, + listen: Function, + block: (message: string) => Unblock, + block: ((location: HashLocation, action: Action) => string) => Unblock, + push: (path: string) => void, + }; + + declare type HistoryOpts = { + basename?: string, + hashType: "slash" | "noslash" | "hashbang", + getUserConfirmation?: ( + message: string, + callback: (willContinue: boolean) => void, + ) => void, + }; + + declare export default (opts?: HistoryOpts) => HashHistory; +} diff --git a/scm-ui/package.json b/scm-ui/package.json index c56f88f9e1..8513981bf5 100644 --- a/scm-ui/package.json +++ b/scm-ui/package.json @@ -3,25 +3,30 @@ "version": "0.1.0", "private": true, "dependencies": { - "ces-theme": "https://github.com/cloudogu/ces-theme.git", "classnames": "^2.2.5", + "flow-bin": "^0.75.0", + "history": "^4.7.2", "react": "^16.4.1", "react-dom": "^16.4.1", "react-jss": "^8.6.0", "react-redux": "^5.0.7", - "react-scripts": "1.1.4", - "redux": "^4.0.0", - "redux-logger": "^3.0.6", - "redux-thunk": "^2.3.0", - "history": "^4.7.2", "react-router-dom": "^4.3.1", "react-router-redux": "^5.0.0-alpha.9", - "redux-devtools-extension": "^2.13.5" + "react-scripts": "1.1.4", + "redux": "^4.0.0", + "redux-devtools-extension": "^2.13.5", + "redux-logger": "^3.0.6", + "redux-thunk": "^2.3.0" }, "scripts": { "start": "react-scripts start", "build": "react-scripts build", "test": "react-scripts test --env=jsdom", - "eject": "react-scripts eject" + "eject": "react-scripts eject", + "flow": "flow" + }, + "proxy": "http://localhost:8081/scm", + "devDependencies": { + "prettier": "^1.13.7" } } diff --git a/scm-ui/pom.xml b/scm-ui/pom.xml new file mode 100644 index 0000000000..aa80884abc --- /dev/null +++ b/scm-ui/pom.xml @@ -0,0 +1,18 @@ + + + + 4.0.0 + + + sonia.scm + scm + 2.0.0-SNAPSHOT + + + sonia.scm.clients + scm-ui + pom + 2.0.0-SNAPSHOT + scm-ui + + diff --git a/scm-ui/src/App.js b/scm-ui/src/App.js deleted file mode 100644 index 79083246bd..0000000000 --- a/scm-ui/src/App.js +++ /dev/null @@ -1,20 +0,0 @@ -import React, { Component } from 'react'; -import Navigation from './Navigation'; -import Main from './Main'; -import {withRouter} from 'react-router-dom'; -import 'ces-theme/dist/css/ces.css'; - - - -class App extends Component { - render() { - return ( -
- -
-
- ); - } -} - -export default withRouter(App); diff --git a/scm-ui/src/apiclient.js b/scm-ui/src/apiclient.js new file mode 100644 index 0000000000..f6b04a59a1 --- /dev/null +++ b/scm-ui/src/apiclient.js @@ -0,0 +1,64 @@ +// @flow + +// get api base url from environment +const apiUrl = process.env.API_URL || process.env.PUBLIC_URL || ""; + +export const PAGE_NOT_FOUND_ERROR = Error("page not found"); + +// fetch does not send the X-Requested-With header (https://github.com/github/fetch/issues/17), +// but we need the header to detect ajax request (AjaxAwareAuthenticationRedirectStrategy). +const fetchOptions: RequestOptions = { + credentials: "same-origin", + headers: { + "X-Requested-With": "XMLHttpRequest" + } +}; + +function handleStatusCode(response: Response) { + if (!response.ok) { + if (response.status === 401) { + return response; + } + if (response.status === 404) { + throw PAGE_NOT_FOUND_ERROR; + } + throw new Error("server returned status code " + response.status); + } + return response; +} + +function createUrl(url: string) { + return `${apiUrl}/api/rest/v2/${url}`; +} + +class ApiClient { + get(url: string) { + return fetch(createUrl(url), fetchOptions).then(handleStatusCode); + } + + post(url: string, payload: any) { + return this.httpRequestWithJSONBody(url, payload, "POST"); + } + + delete(url: string, payload: any) { + let options: RequestOptions = { + method: "DELETE" + }; + options = Object.assign(options, fetchOptions); + return fetch(createUrl(url), options).then(handleStatusCode); + } + + httpRequestWithJSONBody(url: string, payload: any, method: string) { + let options: RequestOptions = { + method: method, + body: JSON.stringify(payload) + }; + options = Object.assign(options, fetchOptions); + // $FlowFixMe + options.headers["Content-Type"] = "application/json"; + + return fetch(createUrl(url), options).then(handleStatusCode); + } +} + +export let apiClient = new ApiClient(); diff --git a/scm-ui/src/containers/App.js b/scm-ui/src/containers/App.js new file mode 100644 index 0000000000..e03221ec6a --- /dev/null +++ b/scm-ui/src/containers/App.js @@ -0,0 +1,35 @@ +import React, { Component } from "react"; +import Navigation from "./Navigation"; +import Main from "./Main"; +import Login from "./Login"; +import { withRouter } from "react-router-dom"; + +type Props = { + login: boolean +} + +class App extends Component { + + render() { + + const { login} = this.props; + + if(login) { + return ( +
+ +
+ ); + } + else { + return ( +
+ +
+
+ ); + } + } +} + +export default withRouter(App); diff --git a/scm-ui/src/Login.js b/scm-ui/src/containers/Login.js similarity index 100% rename from scm-ui/src/Login.js rename to scm-ui/src/containers/Login.js diff --git a/scm-ui/src/Main.js b/scm-ui/src/containers/Main.js similarity index 85% rename from scm-ui/src/Main.js rename to scm-ui/src/containers/Main.js index 10e7d4f0c2..8f15455078 100644 --- a/scm-ui/src/Main.js +++ b/scm-ui/src/containers/Main.js @@ -5,8 +5,8 @@ import classNames from 'classnames'; import { Route, withRouter } from 'react-router'; -import Repositories from './containers/Repositories'; -import Users from './containers/Users'; +import Repositories from '../repositories/containers/Repositories'; +import Users from '../users/containers/Users'; import {Switch} from 'react-router-dom'; const styles = { diff --git a/scm-ui/src/Navigation.js b/scm-ui/src/containers/Navigation.js similarity index 100% rename from scm-ui/src/Navigation.js rename to scm-ui/src/containers/Navigation.js diff --git a/scm-ui/src/createReduxStore.js b/scm-ui/src/createReduxStore.js index 5077df3e54..502fc55faf 100644 --- a/scm-ui/src/createReduxStore.js +++ b/scm-ui/src/createReduxStore.js @@ -1,12 +1,15 @@ -import thunk from 'redux-thunk'; -import logger from 'redux-logger'; -import { createStore, compose, applyMiddleware, combineReducers } from 'redux'; -import { routerReducer, routerMiddleware } from 'react-router-redux'; +// @flow +import thunk from "redux-thunk"; +import logger from "redux-logger"; +import { createStore, compose, applyMiddleware, combineReducers } from "redux"; +import { routerReducer, routerMiddleware } from "react-router-redux"; -import repositories from './modules/repositories'; -import users from './modules/users'; +import repositories from "./repositories/modules/repositories"; +import users from "./users/modules/users"; -function createReduxStore(history) { +import type {BrowserHistory} from "history/createBrowserHistory"; + +function createReduxStore(history: BrowserHistory) { const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; const reducer = combineReducers({ diff --git a/scm-ui/src/index.js b/scm-ui/src/index.js index 12d8f58f94..564249494a 100644 --- a/scm-ui/src/index.js +++ b/scm-ui/src/index.js @@ -1,32 +1,41 @@ -import React from 'react'; -import ReactDOM from 'react-dom'; -import App from './App'; -import registerServiceWorker from './registerServiceWorker'; +// @flow +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./containers/App"; +import registerServiceWorker from "./registerServiceWorker"; -import { Provider } from 'react-redux'; -import createHistory from 'history/createBrowserHistory'; -import createReduxStore from './createReduxStore'; -import { ConnectedRouter } from 'react-router-redux'; +import { Provider } from "react-redux"; +import createHistory from "history/createBrowserHistory"; +import type { BrowserHistory } from "history/createBrowserHistory"; + +import createReduxStore from "./createReduxStore"; +import { ConnectedRouter } from "react-router-redux"; + +const publicUrl: string = process.env.PUBLIC_URL || ""; // Create a history of your choosing (we're using a browser history in this case) -const history = createHistory({ - basename: process.env.PUBLIC_URL +const history: BrowserHistory = createHistory({ + basename: publicUrl }); -window.appHistory = history; // Add the reducer to your store on the `router` key // Also apply our middleware for navigating const store = createReduxStore(history); +const root = document.getElementById("root"); +if (!root) { + throw new Error("could not find root element"); +} + ReactDOM.render( - { /* ConnectedRouter will use the store from Provider automatically */} - - - + {/* ConnectedRouter will use the store from Provider automatically */} + + + , - document.getElementById('root') + root ); registerServiceWorker(); diff --git a/scm-ui/src/containers/Repositories.js b/scm-ui/src/repositories/containers/Repositories.js similarity index 79% rename from scm-ui/src/containers/Repositories.js rename to scm-ui/src/repositories/containers/Repositories.js index cb4aca4373..5df04cbc4c 100644 --- a/scm-ui/src/containers/Repositories.js +++ b/scm-ui/src/repositories/containers/Repositories.js @@ -3,12 +3,14 @@ import React from 'react'; import { connect } from 'react-redux'; import { fetchRepositoriesIfNeeded } from '../modules/repositories'; -import Login from '../Login'; +import Login from '../../containers/Login'; type Props = { login: boolean, - error: any, + error: Error, + repositories: any, + fetchRepositoriesIfNeeded: () => void } class Repositories extends React.Component { @@ -21,16 +23,7 @@ class Repositories extends React.Component { const { login, error, repositories } = this.props; - if(login) { - return ( -
-

SCM

- -
- ); - } - else if(!login){ - return ( + return (

SCM

Startpage

@@ -38,8 +31,7 @@ class Repositories extends React.Component { Users hier!
- ); - } + ) } diff --git a/scm-ui/src/modules/repositories.js b/scm-ui/src/repositories/modules/repositories.js similarity index 99% rename from scm-ui/src/modules/repositories.js rename to scm-ui/src/repositories/modules/repositories.js index c58f613330..e4ab290a44 100644 --- a/scm-ui/src/modules/repositories.js +++ b/scm-ui/src/repositories/modules/repositories.js @@ -1,4 +1,3 @@ -//@flow const FETCH_REPOSITORIES = 'scm/repositories/FETCH'; const FETCH_REPOSITORIES_SUCCESS = 'scm/repositories/FETCH_SUCCESS'; const FETCH_REPOSITORIES_FAILURE = 'scm/repositories/FETCH_FAILURE'; diff --git a/scm-ui/src/containers/Users.js b/scm-ui/src/users/containers/Users.js similarity index 81% rename from scm-ui/src/containers/Users.js rename to scm-ui/src/users/containers/Users.js index 1e4b0bfba8..e3f3c5190d 100644 --- a/scm-ui/src/containers/Users.js +++ b/scm-ui/src/users/containers/Users.js @@ -3,7 +3,7 @@ import React from 'react'; import { connect } from 'react-redux'; import { fetchUsersIfNeeded } from '../modules/users'; -import Login from '../Login'; +import Login from '../../containers/Login'; type Props = { login: boolean, @@ -22,23 +22,13 @@ class Users extends React.Component { const { login, error, users } = this.props; - - if(login) { - return ( -
-

SCM

- -
- ); - } - else if(!login){ return (

SCM

Users

); - } + } } diff --git a/scm-ui/src/modules/users.js b/scm-ui/src/users/modules/users.js similarity index 96% rename from scm-ui/src/modules/users.js rename to scm-ui/src/users/modules/users.js index 2afdfdfd8a..ed92f7ba10 100644 --- a/scm-ui/src/modules/users.js +++ b/scm-ui/src/users/modules/users.js @@ -1,10 +1,7 @@ -//@flow const FETCH_USERS = 'scm/users/FETCH'; const FETCH_USERS_SUCCESS= 'scm/users/FETCH_SUCCESS'; const FETCH_USERS_FAILURE = 'scm/users/FETCH_FAILURE'; -const THRESHOLD_TIMESTAMP = 10000; - function requestUsers() { return { type: FETCH_USERS diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java new file mode 100644 index 0000000000..ed9733fdef --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/AuthenticationResource.java @@ -0,0 +1,269 @@ +package sonia.scm.api.v2.resources; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.google.common.base.Preconditions; +import com.google.common.base.Strings; +import com.google.inject.Inject; +import com.webcohesion.enunciate.metadata.rs.ResponseCode; +import com.webcohesion.enunciate.metadata.rs.StatusCodes; +import org.apache.shiro.SecurityUtils; +import org.apache.shiro.authc.AuthenticationException; +import org.apache.shiro.authc.DisabledAccountException; +import org.apache.shiro.authc.ExcessiveAttemptsException; +import org.apache.shiro.subject.Subject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.api.rest.RestActionResult; +import sonia.scm.security.*; +import sonia.scm.util.HttpUtil; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import javax.ws.rs.*; +import javax.ws.rs.core.Context; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import javax.xml.bind.annotation.XmlAccessType; +import javax.xml.bind.annotation.XmlAccessorType; +import javax.xml.bind.annotation.XmlRootElement; +import java.util.List; + +/** + * Created by masuewer on 04.07.18. + */ +@Path(AuthenticationResource.PATH) +public class AuthenticationResource { + + private static final Logger LOG = LoggerFactory.getLogger(AuthenticationResource.class); + + public static final String PATH = "v2/auth"; + + private final AccessTokenBuilderFactory tokenBuilderFactory; + private final AccessTokenCookieIssuer cookieIssuer; + + @Inject + public AuthenticationResource(AccessTokenBuilderFactory tokenBuilderFactory, AccessTokenCookieIssuer cookieIssuer) + { + this.tokenBuilderFactory = tokenBuilderFactory; + this.cookieIssuer = cookieIssuer; + } + + + @POST + @Path("access_token") + @Consumes(MediaType.APPLICATION_FORM_URLENCODED) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 400, condition = "bad request, required parameter is missing"), + @ResponseCode(code = 401, condition = "unauthorized, the specified username or password is wrong"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response authenticateViaForm( + @Context HttpServletRequest request, + @Context HttpServletResponse response, + @BeanParam AuthenticationRequest authentication + ) { + return authenticate(request, response, authentication); + } + + @POST + @Path("access_token") + @Consumes(MediaType.APPLICATION_JSON) + @StatusCodes({ + @ResponseCode(code = 200, condition = "success"), + @ResponseCode(code = 400, condition = "bad request, required parameter is missing"), + @ResponseCode(code = 401, condition = "unauthorized, the specified username or password is wrong"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response authenticateViaJSONBody( + @Context HttpServletRequest request, + @Context HttpServletResponse response, + AuthenticationRequest authentication + ) { + return authenticate(request, response, authentication); + } + + private Response authenticate( + HttpServletRequest request, + HttpServletResponse response, + AuthenticationRequest authentication + ) { + authentication.validate(); + + Response res; + Subject subject = SecurityUtils.getSubject(); + + try + { + subject.login(Tokens.createAuthenticationToken(request, authentication.getUsername(), authentication.getPassword())); + + AccessTokenBuilder tokenBuilder = tokenBuilderFactory.create(); + if ( authentication.getScope() != null ) { + tokenBuilder.scope(Scope.valueOf(authentication.getScope())); + } + + AccessToken token = tokenBuilder.build(); + + if (authentication.isCookie()) { + cookieIssuer.authenticate(request, response, token); + res = Response.noContent().build(); + } else { + res = Response.ok( token.compact() ).build(); + } + } + catch (DisabledAccountException ex) + { + if (LOG.isTraceEnabled()) + { + LOG.trace( + "authentication failed, account user ".concat(authentication.getUsername()).concat( + " is locked"), ex); + } + else + { + LOG.warn("authentication failed, account {} is locked", authentication.getUsername()); + } + + res = handleFailedAuthentication(request, ex, Response.Status.FORBIDDEN, + WUIAuthenticationFailure.LOCKED); + } + catch (ExcessiveAttemptsException ex) + { + if (LOG.isTraceEnabled()) + { + LOG.trace( + "authentication failed, account user ".concat(authentication.getUsername()).concat( + " is temporary locked"), ex); + } + else + { + LOG.warn("authentication failed, account {} is temporary locked", authentication.getUsername()); + } + + res = handleFailedAuthentication(request, ex, Response.Status.FORBIDDEN, + WUIAuthenticationFailure.TEMPORARY_LOCKED); + } + catch (AuthenticationException ex) + { + if (LOG.isTraceEnabled()) + { + LOG.trace("authentication failed for user ".concat(authentication.getUsername()), ex); + } + else + { + LOG.warn("authentication failed for user {}", authentication.getUsername()); + } + + res = handleFailedAuthentication(request, ex, Response.Status.UNAUTHORIZED, + WUIAuthenticationFailure.WRONG_CREDENTIALS); + } + + return res; + } + + @DELETE + @Path("access_token") + @StatusCodes({ + @ResponseCode(code = 204, condition = "success"), + @ResponseCode(code = 500, condition = "internal server error") + }) + public Response logout(@Context HttpServletRequest request, @Context HttpServletResponse response) + { + Subject subject = SecurityUtils.getSubject(); + + subject.logout(); + + // remove authentication cookie + cookieIssuer.invalidate(request, response); + + // TODO anonymous access ?? + return Response.noContent().build(); + } + + public static class AuthenticationRequest { + + @FormParam("grant_type") + @JsonProperty("grant_type") + private String grantType; + + @FormParam("username") + private String username; + + @FormParam("password") + private String password; + + @FormParam("cookie") + private boolean cookie; + + @FormParam("scope") + private List scope; + + public String getGrantType() { + return grantType; + } + + public String getUsername() { + return username; + } + + public String getPassword() { + return password; + } + + public boolean isCookie() { + return cookie; + } + + public List getScope() { + return scope; + } + + public void validate() { + Preconditions.checkArgument(!Strings.isNullOrEmpty(grantType), "grant_type parameter is required"); + Preconditions.checkArgument(!Strings.isNullOrEmpty(username), "username parameter is required"); + Preconditions.checkArgument(!Strings.isNullOrEmpty(password), "password parameter is required"); + } + } + + + private Response handleFailedAuthentication(HttpServletRequest request, + AuthenticationException ex, Response.Status status, + WUIAuthenticationFailure failure) { + Response response; + + if (HttpUtil.isWUIRequest(request)) { + response = Response.ok(new WUIAuthenticationFailedResult(failure, + ex.getMessage())).build(); + } else { + response = Response.status(status).build(); + } + + return response; + } + + private enum WUIAuthenticationFailure { LOCKED, TEMPORARY_LOCKED, WRONG_CREDENTIALS } + + @XmlRootElement(name = "result") + @XmlAccessorType(XmlAccessType.FIELD) + private static final class WUIAuthenticationFailedResult extends RestActionResult { + + private final WUIAuthenticationFailure failure; + private final String message; + + public WUIAuthenticationFailedResult(WUIAuthenticationFailure failure, String message) { + super(false); + this.failure = failure; + this.message = message; + } + + public WUIAuthenticationFailure getFailure() { + return failure; + } + + public String getMessage() { + return message; + } + + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/filter/SecurityFilter.java b/scm-webapp/src/main/java/sonia/scm/filter/SecurityFilter.java index e94b6a3aee..07475e5853 100644 --- a/scm-webapp/src/main/java/sonia/scm/filter/SecurityFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/filter/SecurityFilter.java @@ -44,6 +44,7 @@ import org.apache.shiro.subject.Subject; import sonia.scm.Priority; import sonia.scm.SCMContext; import sonia.scm.config.ScmConfiguration; +import sonia.scm.security.SecurityRequests; import sonia.scm.web.filter.HttpFilter; import sonia.scm.web.filter.SecurityHttpServletRequestWrapper; @@ -72,6 +73,8 @@ public class SecurityFilter extends HttpFilter /** Field description */ public static final String URL_AUTHENTICATION = "/api/rest/auth"; + public static final String URLV2_AUTHENTICATION = "/api/rest/v2/auth"; + //~--- constructors --------------------------------------------------------- /** @@ -104,10 +107,7 @@ public class SecurityFilter extends HttpFilter HttpServletResponse response, FilterChain chain) throws IOException, ServletException { - String uri = - request.getRequestURI().substring(request.getContextPath().length()); - - if (!uri.startsWith(URL_AUTHENTICATION)) + if (!SecurityRequests.isAuthenticationRequest(request)) { Subject subject = SecurityUtils.getSubject(); if (hasPermission(subject)) diff --git a/scm-webapp/src/main/java/sonia/scm/security/SecurityRequests.java b/scm-webapp/src/main/java/sonia/scm/security/SecurityRequests.java new file mode 100644 index 0000000000..225767cd3b --- /dev/null +++ b/scm-webapp/src/main/java/sonia/scm/security/SecurityRequests.java @@ -0,0 +1,24 @@ +package sonia.scm.security; + +import javax.servlet.http.HttpServletRequest; +import java.util.regex.Pattern; + +/** + * Created by masuewer on 04.07.18. + */ +public final class SecurityRequests { + + private static final Pattern URI_LOGIN_PATTERN = Pattern.compile("/api/rest(?:/v2)?/auth/access_token"); + + private SecurityRequests() {} + + public static boolean isAuthenticationRequest(HttpServletRequest request) { + String uri = request.getRequestURI().substring(request.getContextPath().length()); + return isAuthenticationRequest(uri); + } + + public static boolean isAuthenticationRequest(String uri) { + return URI_LOGIN_PATTERN.matcher(uri).matches(); + } + +} diff --git a/scm-webapp/src/main/java/sonia/scm/web/security/ApiAuthenticationFilter.java b/scm-webapp/src/main/java/sonia/scm/web/security/ApiAuthenticationFilter.java index 8340225872..d8fe469af9 100644 --- a/scm-webapp/src/main/java/sonia/scm/web/security/ApiAuthenticationFilter.java +++ b/scm-webapp/src/main/java/sonia/scm/web/security/ApiAuthenticationFilter.java @@ -36,24 +36,22 @@ package sonia.scm.web.security; //~--- non-JDK imports -------------------------------------------------------- import com.google.inject.Inject; - import sonia.scm.Priority; import sonia.scm.config.ScmConfiguration; import sonia.scm.filter.Filters; import sonia.scm.filter.WebElement; -import sonia.scm.web.filter.AuthenticationFilter; +import sonia.scm.security.SecurityRequests; import sonia.scm.web.WebTokenGenerator; - -//~--- JDK imports ------------------------------------------------------------ - -import java.io.IOException; - -import java.util.Set; +import sonia.scm.web.filter.AuthenticationFilter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Set; + +//~--- JDK imports ------------------------------------------------------------ /** * Filter to handle authentication for the rest api of SCM-Manager. @@ -66,9 +64,6 @@ import javax.servlet.http.HttpServletResponse; public class ApiAuthenticationFilter extends AuthenticationFilter { - /** login uri */ - public static final String URI_LOGIN = "/api/rest/auth/access_token"; - //~--- constructors --------------------------------------------------------- /** @@ -104,7 +99,7 @@ public class ApiAuthenticationFilter extends AuthenticationFilter throws IOException, ServletException { // skip filter on login resource - if (request.getRequestURI().contains(URI_LOGIN)) + if (SecurityRequests.isAuthenticationRequest(request)) { chain.doFilter(request, response); } diff --git a/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestsTest.java b/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestsTest.java new file mode 100644 index 0000000000..9e6d54dc0b --- /dev/null +++ b/scm-webapp/src/test/java/sonia/scm/security/SecurityRequestsTest.java @@ -0,0 +1,37 @@ +package sonia.scm.security; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.mockito.Mock; +import org.mockito.runners.MockitoJUnitRunner; + +import javax.servlet.http.HttpServletRequest; + +import static org.junit.Assert.*; +import static org.mockito.Mockito.when; + +/** + * Created by masuewer on 04.07.18. + */ +@RunWith(MockitoJUnitRunner.class) +public class SecurityRequestsTest { + + @Mock + private HttpServletRequest request; + + @Test + public void testIsAuthenticationRequestWithContextPath() { + when(request.getRequestURI()).thenReturn("/scm/api/rest/auth/access_token"); + when(request.getContextPath()).thenReturn("/scm"); + + assertTrue(SecurityRequests.isAuthenticationRequest(request)); + } + + @Test + public void testIsAuthenticationRequest() throws Exception { + assertTrue(SecurityRequests.isAuthenticationRequest("/api/rest/auth/access_token")); + assertTrue(SecurityRequests.isAuthenticationRequest("/api/rest/v2/auth/access_token")); + assertFalse(SecurityRequests.isAuthenticationRequest("/api/rest/repositories")); + assertFalse(SecurityRequests.isAuthenticationRequest("/api/rest/v2/repositories")); + } +}