aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJulien Lancelot <julien.lancelot@sonarsource.com>2016-06-14 11:27:10 +0200
committerJulien Lancelot <julien.lancelot@sonarsource.com>2016-06-15 11:08:36 +0200
commit6f49a9466aaadfad0073c571e023436a9f96bc73 (patch)
treefdca4e209802d138f823c10f2bdffaddc90648d9
parent59a1bedcce44674f3bac75af59ddcd5cb985e303 (diff)
downloadsonarqube-6f49a9466aaadfad0073c571e023436a9f96bc73.tar.gz
sonarqube-6f49a9466aaadfad0073c571e023436a9f96bc73.zip
SONAR-7713 Use JWT session
-rw-r--r--pom.xml21
-rw-r--r--server/sonar-server/pom.xml4
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java6
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/CookieUtils.java43
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java30
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/GenerateJwtTokenFilter.java141
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java165
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/JwtSerializer.java175
-rw-r--r--server/sonar-server/src/main/java/org/sonar/server/authentication/ValidateJwtTokenFilter.java72
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/authentication/GenerateJwtTokenFilterTest.java105
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/authentication/JwtHttpHandlerTest.java304
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/authentication/JwtSerializerTest.java309
-rw-r--r--server/sonar-server/src/test/java/org/sonar/server/authentication/ValidateJwtTokenFilterTest.java76
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb2
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb2
-rw-r--r--server/sonar-web/src/main/webapp/WEB-INF/web.xml5
16 files changed, 1442 insertions, 18 deletions
diff --git a/pom.xml b/pom.xml
index c7bc0bce6d9..794d325883a 100644
--- a/pom.xml
+++ b/pom.xml
@@ -68,6 +68,7 @@
<elasticsearch.version>2.3.3</elasticsearch.version>
<orchestrator.version>3.11</orchestrator.version>
<okhttp.version>2.6.0</okhttp.version>
+ <jackson.version>2.6.6</jackson.version>
<protobuf.version>3.0.0-beta-2</protobuf.version>
<protobuf.compiler>${settings.localRepository}/com/google/protobuf/protoc/${protobuf.version}/protoc-${protobuf.version}-${os.detected.classifier}.exe</protobuf.compiler>
@@ -644,6 +645,26 @@
</exclusions>
</dependency>
<dependency>
+ <groupId>io.jsonwebtoken</groupId>
+ <artifactId>jjwt</artifactId>
+ <version>0.6.0</version>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-core</artifactId>
+ <version>${jackson.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-databind</artifactId>
+ <version>${jackson.version}</version>
+ </dependency>
+ <dependency>
+ <groupId>com.fasterxml.jackson.core</groupId>
+ <artifactId>jackson-annotations</artifactId>
+ <version>${jackson.version}</version>
+ </dependency>
+ <dependency>
<groupId>org.mybatis</groupId>
<artifactId>mybatis</artifactId>
<version>3.2.7</version>
diff --git a/server/sonar-server/pom.xml b/server/sonar-server/pom.xml
index 61954cd6395..8fdd868fec4 100644
--- a/server/sonar-server/pom.xml
+++ b/server/sonar-server/pom.xml
@@ -188,6 +188,10 @@
<groupId>${project.groupId}</groupId>
<artifactId>sonar-plugin-bridge</artifactId>
</dependency>
+ <dependency>
+ <groupId>io.jsonwebtoken</groupId>
+ <artifactId>jjwt</artifactId>
+ </dependency>
<!-- unit tests -->
<dependency>
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java
index 2ecebed37bd..2ad26f657ef 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java
@@ -33,6 +33,10 @@ public class AuthenticationModule extends Module {
BaseContextFactory.class,
OAuth2ContextFactory.class,
UserIdentityAuthenticator.class,
- CsrfVerifier.class);
+ CsrfVerifier.class,
+ GenerateJwtTokenFilter.class,
+ ValidateJwtTokenFilter.class,
+ JwtSerializer.class,
+ JwtHttpHandler.class);
}
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/CookieUtils.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/CookieUtils.java
new file mode 100644
index 00000000000..8814772aa79
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/CookieUtils.java
@@ -0,0 +1,43 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import java.util.Arrays;
+import java.util.Optional;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+
+public class CookieUtils {
+
+ private CookieUtils() {
+ // Only static methods
+ }
+
+ public static Optional<Cookie> findCookie(String cookieName, HttpServletRequest request) {
+ Cookie[] cookies = request.getCookies();
+ if (cookies == null) {
+ return Optional.empty();
+ }
+ return Arrays.stream(cookies)
+ .filter(cookie -> cookieName.equals(cookie.getName()))
+ .findFirst();
+ }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java
index 20ea1417e23..60ccd965184 100644
--- a/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java
@@ -19,17 +19,18 @@
*/
package org.sonar.server.authentication;
+import static org.apache.commons.codec.digest.DigestUtils.sha256Hex;
+import static org.apache.commons.lang.StringUtils.isBlank;
+
import java.math.BigInteger;
import java.security.SecureRandom;
+import java.util.Optional;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.sonar.api.platform.Server;
import org.sonar.server.exceptions.UnauthorizedException;
-import static org.apache.commons.codec.digest.DigestUtils.sha256Hex;
-import static org.apache.commons.lang.StringUtils.isBlank;
-
public class CsrfVerifier {
private static final String CSRF_STATE_COOKIE = "OAUTHSTATE";
@@ -54,27 +55,24 @@ public class CsrfVerifier {
}
public void verifyState(HttpServletRequest request, HttpServletResponse response) {
- Cookie stateCookie = null;
- Cookie[] cookies = request.getCookies();
- for (Cookie cookie : cookies) {
- if (CSRF_STATE_COOKIE.equals(cookie.getName())) {
- stateCookie = cookie;
- }
- }
- if (stateCookie == null) {
+ Optional<Cookie> stateCookie = CookieUtils.findCookie(CSRF_STATE_COOKIE, request);
+ if (!stateCookie.isPresent()) {
throw new UnauthorizedException();
}
- String hashInCookie = stateCookie.getValue();
+ Cookie cookie = stateCookie.get();
+
+ String hashInCookie = cookie.getValue();
// remove cookie
- stateCookie.setValue(null);
- stateCookie.setMaxAge(0);
- stateCookie.setPath(server.getContextPath() + "/");
- response.addCookie(stateCookie);
+ cookie.setValue(null);
+ cookie.setMaxAge(0);
+ cookie.setPath(server.getContextPath() + "/");
+ response.addCookie(cookie);
String stateInRequest = request.getParameter("state");
if (isBlank(stateInRequest) || !sha256Hex(stateInRequest).equals(hashInCookie)) {
throw new UnauthorizedException();
}
}
+
}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/GenerateJwtTokenFilter.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/GenerateJwtTokenFilter.java
new file mode 100644
index 00000000000..24f206b64b8
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/GenerateJwtTokenFilter.java
@@ -0,0 +1,141 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.PrintWriter;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.WriteListener;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpServletResponseWrapper;
+import org.sonar.api.web.ServletFilter;
+import org.sonar.server.user.UserSession;
+
+/**
+ * This filter creates web session when user authenticates on URL "/sessions/login".
+ * The generated session is server stateless.
+ */
+public class GenerateJwtTokenFilter extends ServletFilter {
+
+ private static final String POST = "POST";
+
+ private final JwtHttpHandler jwtHttpHandler;
+ private final UserSession userSession;
+
+ public GenerateJwtTokenFilter(JwtHttpHandler jwtHttpHandler, UserSession userSession) {
+ this.jwtHttpHandler = jwtHttpHandler;
+ this.userSession = userSession;
+ }
+
+ @Override
+ public UrlPattern doGetPattern() {
+ return UrlPattern.create("/sessions/login");
+ }
+
+ @Override
+ public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest request = (HttpServletRequest) servletRequest;
+ HttpServletResponse response = (HttpServletResponse) servletResponse;
+
+ if (request.getMethod().equals(POST)) {
+ BufferResponseWrapper wrapper = new BufferResponseWrapper(response);
+ chain.doFilter(request, wrapper);
+ if (userSession.isLoggedIn()) {
+ jwtHttpHandler.generateToken(userSession.getLogin(), response);
+ }
+ response.getOutputStream().write(wrapper.getWrapperBytes());
+ } else {
+ chain.doFilter(request, response);
+ }
+ }
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+ // Nothing to do
+ }
+
+ @Override
+ public void destroy() {
+ // Nothing to do
+ }
+
+ /**
+ * As the RackFilter is executed before this filter, the reponse is commited and it's not possible anymore to add cookie.
+ * So we're create a buffer response wrapper that will buffer the dat that should be send to the browser in order to not commit the response.
+ * It's then possible to add cookie before flushing data to the browser.
+ *
+ * See <a href="http://stackoverflow.com/questions/11025605/response-is-committing-and-dofilter-chain-is-broken">
+ *
+ * Note : this must be removed when authentication will not use rails anymore
+ */
+ private static final class BufferResponseWrapper extends HttpServletResponseWrapper {
+
+ private BufferServletOutputStream stream = new BufferServletOutputStream();
+
+ BufferResponseWrapper(HttpServletResponse httpServletResponse) {
+ super(httpServletResponse);
+ }
+
+ @Override
+ public ServletOutputStream getOutputStream() throws IOException {
+ return stream;
+ }
+
+ @Override
+ public PrintWriter getWriter() throws IOException {
+ return new PrintWriter(stream);
+ }
+
+ byte[] getWrapperBytes() {
+ return stream.getBytes();
+ }
+ }
+
+ private static final class BufferServletOutputStream extends ServletOutputStream {
+ private ByteArrayOutputStream out = new ByteArrayOutputStream();
+
+ @Override
+ public void write(int b) throws IOException {
+ out.write(b);
+ }
+
+ byte[] getBytes() {
+ return out.toByteArray();
+ }
+
+ @Override
+ public boolean isReady() {
+ return false;
+ }
+
+ @Override
+ public void setWriteListener(WriteListener listener) {
+ // Nothing to do
+ }
+ }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java
new file mode 100644
index 00000000000..30c1567afcb
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java
@@ -0,0 +1,165 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import static java.util.Objects.requireNonNull;
+import static org.elasticsearch.common.Strings.isNullOrEmpty;
+import static org.sonar.server.authentication.CookieUtils.findCookie;
+
+import com.google.common.collect.ImmutableMap;
+import io.jsonwebtoken.Claims;
+import java.util.Date;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.apache.commons.lang.time.DateUtils;
+import org.sonar.api.config.Settings;
+import org.sonar.api.platform.Server;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.utils.System2;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.user.UserDto;
+
+@ServerSide
+public class JwtHttpHandler {
+
+ private static final String SESSION_TIMEOUT_PROPERTY = "sonar.auth.sessionTimeoutInHours";
+ private static final int SESSION_TIMEOUT_DEFAULT_VALUE_IN_SECONDS = 3 * 24 * 60 * 60;
+
+ private static final String JWT_COOKIE = "JWT-SESSION";
+ private static final String LAST_REFRESH_TIME_PARAM = "lastRefreshTime";
+
+ // Time after which a user will be disconnected
+ private static final int SESSION_DISCONNECT_IN_SECONDS = 3 * 30 * 24 * 60 * 60;
+
+ // This refresh time is used to refresh the session
+ // The value must be lower than sessionTimeoutInSeconds
+ private static final int SESSION_REFRESH_IN_SECONDS = 5 * 60;
+
+ private static final String RAILS_USER_ID_SESSION = "user_id";
+
+ private final System2 system2;
+ private final DbClient dbClient;
+ private final Server server;
+ private final JwtSerializer jwtSerializer;
+
+ // This timeout is used to disconnect the user we he has not browse any page for a while
+ private final int sessionTimeoutInSeconds;
+
+ public JwtHttpHandler(System2 system2, DbClient dbClient, Server server, Settings settings, JwtSerializer jwtSerializer) {
+ this.jwtSerializer = jwtSerializer;
+ this.server = server;
+ this.dbClient = dbClient;
+ this.system2 = system2;
+ this.sessionTimeoutInSeconds = getSessionTimeoutInSeconds(settings);
+ }
+
+ void generateToken(String userLogin, HttpServletResponse response) {
+ String token = jwtSerializer.encode(new JwtSerializer.JwtSession(
+ userLogin,
+ sessionTimeoutInSeconds,
+ ImmutableMap.of(LAST_REFRESH_TIME_PARAM, system2.now())));
+ response.addCookie(createCookie(JWT_COOKIE, token, sessionTimeoutInSeconds));
+ }
+
+ void validateToken(HttpServletRequest request, HttpServletResponse response) {
+ Optional<Cookie> jwtCookie = findCookie(JWT_COOKIE, request);
+ if (jwtCookie.isPresent()) {
+ Cookie cookie = jwtCookie.get();
+ String token = cookie.getValue();
+ if (!isNullOrEmpty(token)) {
+ validateToken(token, request, response);
+ }
+ }
+ }
+
+ private void validateToken(String tokenEncoded, HttpServletRequest request, HttpServletResponse response) {
+ Optional<Claims> claims = jwtSerializer.decode(tokenEncoded);
+ if (!claims.isPresent()) {
+ removeSession(request, response);
+ return;
+ }
+
+ Date now = new Date(system2.now());
+
+ Claims token = claims.get();
+ if (now.after(DateUtils.addSeconds(token.getIssuedAt(), SESSION_DISCONNECT_IN_SECONDS))) {
+ removeSession(request, response);
+ return;
+ }
+
+ Optional<UserDto> user = selectUserFromDb(token.getSubject());
+ if (!user.isPresent()) {
+ removeSession(request, response);
+ return;
+ }
+
+ request.getSession().setAttribute(RAILS_USER_ID_SESSION, user.get().getId());
+ if (now.after(DateUtils.addSeconds(getLastRefreshDate(token), SESSION_REFRESH_IN_SECONDS))) {
+ refreshToken(token, response);
+ }
+ }
+
+ private static Date getLastRefreshDate(Claims token) {
+ Long lastFreshTime = (Long) token.get(LAST_REFRESH_TIME_PARAM);
+ requireNonNull(lastFreshTime, "last refresh time is missing in token");
+ return new Date(lastFreshTime);
+ }
+
+ private void refreshToken(Claims token, HttpServletResponse response) {
+ String refreshToken = jwtSerializer.refresh(token, sessionTimeoutInSeconds);
+ response.addCookie(createCookie(JWT_COOKIE, refreshToken, sessionTimeoutInSeconds));
+ }
+
+ private void removeSession(HttpServletRequest request, HttpServletResponse response) {
+ request.getSession().removeAttribute(RAILS_USER_ID_SESSION);
+ response.addCookie(createCookie(JWT_COOKIE, null, 0));
+ }
+
+ private Cookie createCookie(String name, @Nullable String value, int expirationInSeconds) {
+ Cookie cookie = new Cookie(name, value);
+ cookie.setPath(server.getContextPath() + "/");
+ cookie.setSecure(server.isSecured());
+ cookie.setHttpOnly(true);
+ cookie.setMaxAge(expirationInSeconds);
+ return cookie;
+ }
+
+ private Optional<UserDto> selectUserFromDb(String userLogin) {
+ DbSession dbSession = dbClient.openSession(false);
+ try {
+ return Optional.ofNullable(dbClient.userDao().selectActiveUserByLogin(dbSession, userLogin));
+ } finally {
+ dbClient.closeSession(dbSession);
+ }
+ }
+
+ private static int getSessionTimeoutInSeconds(Settings settings) {
+ int propertyFromSettings = settings.getInt(SESSION_TIMEOUT_PROPERTY);
+ if (propertyFromSettings > 0) {
+ return propertyFromSettings * 60 * 60;
+ }
+ return SESSION_TIMEOUT_DEFAULT_VALUE_IN_SECONDS;
+ }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtSerializer.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtSerializer.java
new file mode 100644
index 00000000000..189ee6a6202
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/JwtSerializer.java
@@ -0,0 +1,175 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import static com.google.common.base.Preconditions.checkNotNull;
+import static io.jsonwebtoken.impl.crypto.MacProvider.generateKey;
+import static java.util.Objects.requireNonNull;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
+import io.jsonwebtoken.JwtBuilder;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.SignatureException;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.Date;
+import java.util.Map;
+import java.util.Optional;
+import javax.annotation.concurrent.Immutable;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.sonar.api.Startable;
+import org.sonar.api.config.Settings;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.utils.System2;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.server.exceptions.UnauthorizedException;
+
+/**
+ * This class can be used to encode or decode a JWT token
+ */
+@ServerSide
+public class JwtSerializer implements Startable {
+
+ private static final String SECRET_KEY_PROPERTY = "sonar.auth.jwtBase64Hs256Secret";
+
+ private static final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256;
+
+ private final Settings settings;
+ private final System2 system2;
+ private final UuidFactory uuidFactory;
+
+ private SecretKey secretKey;
+
+ public JwtSerializer(Settings settings, System2 system2, UuidFactory uuidFactory) {
+ this.settings = settings;
+ this.system2 = system2;
+ this.uuidFactory = uuidFactory;
+ }
+
+ @Override
+ public void start() {
+ String encodedKey = settings.getString(SECRET_KEY_PROPERTY);
+ if (encodedKey == null) {
+ SecretKey newSecretKey = generateSecretKey();
+ settings.setProperty(SECRET_KEY_PROPERTY, Base64.getEncoder().encodeToString(newSecretKey.getEncoded()));
+ this.secretKey = newSecretKey;
+ } else {
+ this.secretKey = decodeSecretKeyProperty(encodedKey);
+ }
+ }
+
+ String encode(JwtSession jwtSession) {
+ checkIsStarted();
+ long now = system2.now();
+ JwtBuilder jwtBuilder = Jwts.builder()
+ .setId(uuidFactory.create())
+ .setSubject(jwtSession.getUserLogin())
+ .setIssuedAt(new Date(now))
+ .setExpiration(new Date(now + jwtSession.getExpirationTimeInSeconds() * 1000))
+ .signWith(SIGNATURE_ALGORITHM, secretKey);
+ for (Map.Entry<String, Object> entry : jwtSession.getProperties().entrySet()) {
+ jwtBuilder.claim(entry.getKey(), entry.getValue());
+ }
+ return jwtBuilder.compact();
+ }
+
+ Optional<Claims> decode(String token) {
+ checkIsStarted();
+ try {
+ Claims claims = Jwts.parser()
+ .setSigningKey(secretKey)
+ .parseClaimsJws(token)
+ .getBody();
+ requireNonNull(claims.getId(), "Token id hasn't been found");
+ requireNonNull(claims.getSubject(), "Token subject hasn't been found");
+ requireNonNull(claims.getExpiration(), "Token expiration date hasn't been found");
+ requireNonNull(claims.getIssuedAt(), "Token creation date hasn't been found");
+ return Optional.of(claims);
+ } catch (ExpiredJwtException | SignatureException e) {
+ return Optional.empty();
+ } catch (Exception e) {
+ throw new UnauthorizedException(e.getMessage());
+ }
+ }
+
+ String refresh(Claims token, int expirationTimeInSeconds) {
+ checkIsStarted();
+ long now = system2.now();
+ JwtBuilder jwtBuilder = Jwts.builder();
+ for (Map.Entry<String, Object> entry : token.entrySet()) {
+ jwtBuilder.claim(entry.getKey(), entry.getValue());
+ }
+ jwtBuilder.setExpiration(new Date(now + expirationTimeInSeconds * 1000))
+ .signWith(SIGNATURE_ALGORITHM, secretKey);
+ return jwtBuilder.compact();
+ }
+
+ private static SecretKey generateSecretKey() {
+ return generateKey(SIGNATURE_ALGORITHM);
+ }
+
+ private static SecretKey decodeSecretKeyProperty(String base64SecretKey) {
+ byte[] decodedKey = Base64.getDecoder().decode(base64SecretKey);
+ return new SecretKeySpec(decodedKey, 0, decodedKey.length, SIGNATURE_ALGORITHM.getJcaName());
+ }
+
+ private void checkIsStarted() {
+ checkNotNull(secretKey, "%s not started", getClass().getName());
+ }
+
+ @Override
+ public void stop() {
+ secretKey = null;
+ }
+
+ @Immutable
+ static class JwtSession {
+
+ private final String userLogin;
+ private final int expirationTimeInSeconds;
+ private final Map<String, Object> properties;
+
+ JwtSession(String userLogin, int expirationTimeInSeconds) {
+ this(userLogin, expirationTimeInSeconds, Collections.emptyMap());
+ }
+
+ JwtSession(String userLogin, int expirationTimeInSeconds, Map<String, Object> properties) {
+ this.userLogin = requireNonNull(userLogin, "User login cannot be null");
+ this.expirationTimeInSeconds = expirationTimeInSeconds;
+ this.properties = properties;
+ }
+
+ String getUserLogin() {
+ return userLogin;
+ }
+
+ int getExpirationTimeInSeconds() {
+ return expirationTimeInSeconds;
+ }
+
+ Map<String, Object> getProperties() {
+ return properties;
+ }
+ }
+}
diff --git a/server/sonar-server/src/main/java/org/sonar/server/authentication/ValidateJwtTokenFilter.java b/server/sonar-server/src/main/java/org/sonar/server/authentication/ValidateJwtTokenFilter.java
new file mode 100644
index 00000000000..340360100e3
--- /dev/null
+++ b/server/sonar-server/src/main/java/org/sonar/server/authentication/ValidateJwtTokenFilter.java
@@ -0,0 +1,72 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import java.io.IOException;
+import javax.servlet.FilterChain;
+import javax.servlet.FilterConfig;
+import javax.servlet.ServletException;
+import javax.servlet.ServletRequest;
+import javax.servlet.ServletResponse;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.sonar.api.server.ServerSide;
+import org.sonar.api.web.ServletFilter;
+import org.sonar.server.exceptions.UnauthorizedException;
+
+// TODO do not enter here on static resources
+@ServerSide
+public class ValidateJwtTokenFilter extends ServletFilter {
+
+ private final JwtHttpHandler jwtHttpHandler;
+
+ public ValidateJwtTokenFilter(JwtHttpHandler jwtHttpHandler) {
+ this.jwtHttpHandler = jwtHttpHandler;
+ }
+
+ @Override
+ public UrlPattern doGetPattern() {
+ return UrlPattern.create("/*");
+ }
+
+ @Override
+ public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException {
+ HttpServletRequest request = (HttpServletRequest) servletRequest;
+ HttpServletResponse response = (HttpServletResponse) servletResponse;
+
+ try {
+ jwtHttpHandler.validateToken(request, response);
+ chain.doFilter(request, response);
+ } catch (UnauthorizedException e) {
+ response.setStatus(e.httpCode());
+ }
+ }
+
+ @Override
+ public void init(FilterConfig filterConfig) throws ServletException {
+ // Nothing to do
+ }
+
+ @Override
+ public void destroy() {
+ // Nothing to do
+ }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/GenerateJwtTokenFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/GenerateJwtTokenFilterTest.java
new file mode 100644
index 00000000000..e8939135dca
--- /dev/null
+++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/GenerateJwtTokenFilterTest.java
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.jboss.netty.handler.codec.http.HttpMethod.GET;
+import static org.jboss.netty.handler.codec.http.HttpMethod.POST;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import javax.servlet.FilterChain;
+import javax.servlet.ServletOutputStream;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.server.tester.UserSessionRule;
+
+public class GenerateJwtTokenFilterTest {
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
+ @Rule
+ public UserSessionRule userSession = UserSessionRule.standalone();
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ FilterChain chain = mock(FilterChain.class);
+
+ JwtHttpHandler jwtHttpHandler = mock(JwtHttpHandler.class);
+
+ GenerateJwtTokenFilter underTest = new GenerateJwtTokenFilter(jwtHttpHandler, userSession);
+
+ @Before
+ public void setUp() throws Exception {
+ when(response.getOutputStream()).thenReturn(mock(ServletOutputStream.class));
+ }
+
+ @Test
+ public void do_get_pattern() throws Exception {
+ assertThat(underTest.doGetPattern().getUrl()).isEqualTo("/sessions/login");
+ }
+
+ @Test
+ public void create_session_when_post_request_and_user_is_authenticated() throws Exception {
+ executePostRequest();
+ userSession.login("john");
+
+ underTest.doFilter(request, response, chain);
+
+ verify(jwtHttpHandler).generateToken("john", response);
+ }
+
+ @Test
+ public void does_nothing_on_get_request() throws Exception {
+ executeGetRequest();
+ userSession.login("john");
+
+ underTest.doFilter(request, response, chain);
+
+ verifyZeroInteractions(jwtHttpHandler);
+ }
+
+ @Test
+ public void does_nothing_when_user_is_not_authenticated() throws Exception {
+ executePostRequest();
+ userSession.anonymous();
+
+ underTest.doFilter(request, response, chain);
+
+ verifyZeroInteractions(jwtHttpHandler);
+ }
+
+ private void executePostRequest() {
+ when(request.getMethod()).thenReturn(POST.getName());
+ }
+
+ private void executeGetRequest() {
+ when(request.getMethod()).thenReturn(GET.getName());
+ }
+
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/JwtHttpHandlerTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/JwtHttpHandlerTest.java
new file mode 100644
index 00000000000..fc3efd8b65a
--- /dev/null
+++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/JwtHttpHandlerTest.java
@@ -0,0 +1,304 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Matchers.any;
+import static org.mockito.Matchers.anyInt;
+import static org.mockito.Matchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+import static org.sonar.api.utils.System2.INSTANCE;
+
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.impl.DefaultClaims;
+import java.util.Date;
+import java.util.Optional;
+import javax.annotation.Nullable;
+import javax.servlet.http.Cookie;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import javax.servlet.http.HttpSession;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.mockito.ArgumentCaptor;
+import org.sonar.api.config.Settings;
+import org.sonar.api.platform.Server;
+import org.sonar.api.utils.System2;
+import org.sonar.db.DbClient;
+import org.sonar.db.DbSession;
+import org.sonar.db.DbTester;
+import org.sonar.db.user.UserDto;
+import org.sonar.db.user.UserTesting;
+import org.sonar.server.tester.UserSessionRule;
+
+public class JwtHttpHandlerTest {
+
+ static final String JWT_TOKEN = "TOKEN";
+ static final String USER_LOGIN = "john";
+
+ static final long NOW = 10_000_000_000L;
+ static final long FOUR_MINUTES_AGO = NOW - 4 * 60 * 1000L;
+ static final long SIX_MINUTES_AGO = NOW - 6 * 60 * 1000L;
+ static final long TEN_DAYS_AGO = NOW - 10 * 24 * 60 * 60 * 1000L;
+
+ @Rule
+ public ExpectedException thrown = ExpectedException.none();
+
+ @Rule
+ public UserSessionRule userSession = UserSessionRule.standalone();
+
+ @Rule
+ public DbTester dbTester = DbTester.create(INSTANCE);
+
+ DbClient dbClient = dbTester.getDbClient();
+
+ DbSession dbSession = dbTester.getSession();
+
+ ArgumentCaptor<Cookie> cookieArgumentCaptor = ArgumentCaptor.forClass(Cookie.class);
+ ArgumentCaptor<JwtSerializer.JwtSession> jwtArgumentCaptor = ArgumentCaptor.forClass(JwtSerializer.JwtSession.class);
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ HttpSession httpSession = mock(HttpSession.class);
+
+ System2 system2 = mock(System2.class);
+ Server server = mock(Server.class);
+ Settings settings = new Settings();
+ JwtSerializer jwtSerializer = mock(JwtSerializer.class);
+
+ JwtHttpHandler underTest = new JwtHttpHandler(system2, dbClient, server, settings, jwtSerializer);
+
+ @Before
+ public void setUp() throws Exception {
+ when(system2.now()).thenReturn(NOW);
+ when(server.isSecured()).thenReturn(true);
+ when(server.getContextPath()).thenReturn("");
+ when(request.getSession()).thenReturn(httpSession);
+ when(jwtSerializer.encode(any(JwtSerializer.JwtSession.class))).thenReturn(JWT_TOKEN);
+ }
+
+ @Test
+ public void create_session() throws Exception {
+ underTest.generateToken(USER_LOGIN, response);
+
+ Optional<Cookie> jwtCookie = findCookie("JWT-SESSION");
+ assertThat(jwtCookie).isPresent();
+ verifyCookie(jwtCookie.get(), JWT_TOKEN, 3 * 24 * 60 * 60);
+
+ verify(jwtSerializer).encode(jwtArgumentCaptor.capture());
+ verifyToken(jwtArgumentCaptor.getValue(), 3 * 24 * 60 * 60, NOW);
+ }
+
+ @Test
+ public void validate_session() throws Exception {
+ addJwtCookie();
+ UserDto user = addUser();
+
+ Claims claims = createToken(NOW);
+ when(jwtSerializer.decode(JWT_TOKEN)).thenReturn(Optional.of(claims));
+
+ underTest.validateToken(request, response);
+
+ verify(httpSession).setAttribute("user_id", user.getId());
+ verify(jwtSerializer, never()).encode(any(JwtSerializer.JwtSession.class));
+ }
+
+ @Test
+ public void use_session_timeout_from_settings() throws Exception {
+ int sessionTimeoutInHours = 10;
+ settings.setProperty("sonar.auth.sessionTimeoutInHours", sessionTimeoutInHours);
+
+ underTest = new JwtHttpHandler(system2, dbClient, server, settings, jwtSerializer);
+ underTest.generateToken(USER_LOGIN, response);
+
+ verify(jwtSerializer).encode(jwtArgumentCaptor.capture());
+ verifyToken(jwtArgumentCaptor.getValue(), sessionTimeoutInHours * 60 * 60, NOW);
+ }
+
+ @Test
+ public void session_timeout_property_cannot_be_updated() throws Exception {
+ int firstSessionTimeoutInHours = 10;
+ settings.setProperty("sonar.auth.sessionTimeoutInHours", firstSessionTimeoutInHours);
+
+ underTest = new JwtHttpHandler(system2, dbClient, server, settings, jwtSerializer);
+ underTest.generateToken(USER_LOGIN, response);
+
+ // The property is updated, but it won't be taking into account
+ settings.setProperty("sonar.auth.sessionTimeoutInHours", 15);
+ underTest.generateToken(USER_LOGIN, response);
+ verify(jwtSerializer, times(2)).encode(jwtArgumentCaptor.capture());
+ verifyToken(jwtArgumentCaptor.getAllValues().get(0), firstSessionTimeoutInHours * 60 * 60, NOW);
+ verifyToken(jwtArgumentCaptor.getAllValues().get(1), firstSessionTimeoutInHours * 60 * 60, NOW);
+ }
+
+ @Test
+ public void refresh_session_when_refresh_time_is_reached() throws Exception {
+ addJwtCookie();
+ UserDto user = addUser();
+
+ // Token was created 10 days ago and refreshed 6 minutes ago
+ Claims claims = createToken(TEN_DAYS_AGO);
+ claims.put("lastRefreshTime", SIX_MINUTES_AGO);
+ when(jwtSerializer.decode(JWT_TOKEN)).thenReturn(Optional.of(claims));
+
+ underTest.validateToken(request, response);
+
+ verify(httpSession).setAttribute("user_id", user.getId());
+ verify(jwtSerializer).refresh(any(Claims.class), eq(3 * 24 * 60 * 60));
+ }
+
+ @Test
+ public void does_not_refresh_session_when_refresh_time_is_not_reached() throws Exception {
+ addJwtCookie();
+ UserDto user = addUser();
+
+ // Token was created 10 days ago and refreshed 4 minutes ago
+ Claims claims = createToken(TEN_DAYS_AGO);
+ claims.put("lastRefreshTime", FOUR_MINUTES_AGO);
+ when(jwtSerializer.decode(JWT_TOKEN)).thenReturn(Optional.of(claims));
+
+ underTest.validateToken(request, response);
+
+ verify(httpSession).setAttribute("user_id", user.getId());
+ verify(jwtSerializer, never()).refresh(any(Claims.class), anyInt());
+ }
+
+ @Test
+ public void remove_session_when_disconnected_timeout_is_reached() throws Exception {
+ addJwtCookie();
+ addUser();
+
+ // Token was created 4 months ago, refreshed 4 minutes ago, and it expired in 5 minutes
+ Claims claims = createToken(NOW - (4L * 30 * 24 * 60 * 60 * 1000));
+ claims.setExpiration(new Date(NOW + 5 * 60 * 1000));
+ claims.put("lastRefreshTime", FOUR_MINUTES_AGO);
+ when(jwtSerializer.decode(JWT_TOKEN)).thenReturn(Optional.of(claims));
+
+ underTest.validateToken(request, response);
+
+ verify(httpSession).removeAttribute("user_id");
+ verifyCookie(findCookie("JWT-SESSION").get(), null, 0);
+ }
+
+ @Test
+ public void remove_session_when_user_is_disabled() throws Exception {
+ addJwtCookie();
+ addUser(false);
+
+ Claims claims = createToken(NOW);
+ when(jwtSerializer.decode(JWT_TOKEN)).thenReturn(Optional.of(claims));
+
+ underTest.validateToken(request, response);
+
+ verify(httpSession).removeAttribute("user_id");
+ verifyCookie(findCookie("JWT-SESSION").get(), null, 0);
+ }
+
+ @Test
+ public void remove_session_when_token_is_no_more_valid() throws Exception {
+ addJwtCookie();
+
+ when(jwtSerializer.decode(JWT_TOKEN)).thenReturn(Optional.empty());
+
+ underTest.validateToken(request, response);
+
+ verify(httpSession).removeAttribute("user_id");
+ verifyCookie(findCookie("JWT-SESSION").get(), null, 0);
+ }
+
+ @Test
+ public void does_nothing_when_no_jwt_cookie() throws Exception {
+ underTest.validateToken(request, response);
+
+ verifyZeroInteractions(httpSession, jwtSerializer);
+ }
+
+ @Test
+ public void does_nothing_when_empty_value_in_jwt_cookie() throws Exception {
+ when(request.getCookies()).thenReturn(new Cookie[] {new Cookie("JWT-SESSION", "")});
+
+ underTest.validateToken(request, response);
+
+ verifyZeroInteractions(httpSession, jwtSerializer);
+ }
+
+ private void verifyToken(JwtSerializer.JwtSession token, int expectedExpirationTime, long expectedRefreshTime) {
+ assertThat(token.getExpirationTimeInSeconds()).isEqualTo(expectedExpirationTime);
+ assertThat(token.getUserLogin()).isEqualTo(USER_LOGIN);
+ assertThat(token.getProperties().get("lastRefreshTime")).isEqualTo(expectedRefreshTime);
+ }
+
+ private Optional<Cookie> findCookie(String name) {
+ verify(response).addCookie(cookieArgumentCaptor.capture());
+ return cookieArgumentCaptor.getAllValues().stream()
+ .filter(cookie -> name.equals(cookie.getName()))
+ .findFirst();
+ }
+
+ private void verifyCookie(Cookie cookie, @Nullable String value, int expiry) {
+ assertThat(cookie.getPath()).isEqualTo("/");
+ assertThat(cookie.isHttpOnly()).isTrue();
+ assertThat(cookie.getMaxAge()).isEqualTo(expiry);
+ assertThat(cookie.getSecure()).isEqualTo(true);
+ assertThat(cookie.getValue()).isEqualTo(value);
+ }
+
+ private UserDto addUser() {
+ return addUser(true);
+ }
+
+ private UserDto addUser(boolean active) {
+ UserDto user = UserTesting.newUserDto()
+ .setLogin(USER_LOGIN)
+ .setActive(active);
+ dbClient.userDao().insert(dbSession, user);
+ dbSession.commit();
+ return user;
+ }
+
+ private Cookie addJwtCookie() {
+ Cookie cookie = new Cookie("JWT-SESSION", JWT_TOKEN);
+ when(request.getCookies()).thenReturn(new Cookie[] {cookie});
+ return cookie;
+ }
+
+ private Claims createToken(long createdAt) {
+ // Expired in 5 minutes by default
+ return createToken(createdAt, NOW + 5 * 60 * 1000);
+ }
+
+ private Claims createToken(long createdAt, long expiredAt) {
+ DefaultClaims claims = new DefaultClaims();
+ claims.setId("ID");
+ claims.setSubject(USER_LOGIN);
+ claims.setIssuedAt(new Date(createdAt));
+ claims.setExpiration(new Date(expiredAt));
+ claims.put("lastRefreshTime", createdAt);
+ return claims;
+ }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/JwtSerializerTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/JwtSerializerTest.java
new file mode 100644
index 00000000000..022edfcf058
--- /dev/null
+++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/JwtSerializerTest.java
@@ -0,0 +1,309 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.sonar.server.authentication.JwtSerializer.JwtSession;
+
+import com.google.common.collect.ImmutableMap;
+import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.Jwts;
+import io.jsonwebtoken.SignatureAlgorithm;
+import io.jsonwebtoken.impl.DefaultClaims;
+import java.util.Base64;
+import java.util.Date;
+import java.util.Optional;
+import javax.crypto.SecretKey;
+import javax.crypto.spec.SecretKeySpec;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.rules.ExpectedException;
+import org.sonar.api.config.Settings;
+import org.sonar.api.utils.DateUtils;
+import org.sonar.api.utils.System2;
+import org.sonar.core.util.UuidFactory;
+import org.sonar.core.util.UuidFactoryImpl;
+import org.sonar.server.exceptions.UnauthorizedException;
+
+public class JwtSerializerTest {
+
+ @Rule
+ public ExpectedException expectedException = ExpectedException.none();
+
+ static final String SECRET_KEY = "HrPSavOYLNNrwTY+SOqpChr7OwvbR/zbDLdVXRN0+Eg=";
+
+ static final String USER_LOGIN = "john";
+
+ Settings settings = new Settings();
+ System2 system2 = System2.INSTANCE;
+ UuidFactory uuidFactory = UuidFactoryImpl.INSTANCE;
+
+ JwtSerializer underTest = new JwtSerializer(settings, system2, uuidFactory);
+
+ @Test
+ public void generate_token() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = underTest.encode(new JwtSession(USER_LOGIN, 10));
+
+ assertThat(token).isNotEmpty();
+ }
+
+ @Test
+ public void generate_token_with_expiration_date() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+ Date now = new Date();
+
+ String token = underTest.encode(new JwtSession(USER_LOGIN, 10));
+
+ assertThat(token).isNotEmpty();
+ Claims claims = underTest.decode(token).get();
+ // Check expiration date it set to more than 9 seconds in the futur
+ assertThat(claims.getExpiration()).isAfterOrEqualsTo(new Date(now.getTime() + 9 * 1000));
+ }
+
+ @Test
+ public void generate_token_with_property() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = underTest.encode(new JwtSession(USER_LOGIN, 10, ImmutableMap.of("custom", "property")));
+
+ assertThat(token).isNotEmpty();
+ Claims claims = underTest.decode(token).get();
+ assertThat(claims.get("custom")).isEqualTo("property");
+ }
+
+ @Test
+ public void decode_token() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+ Date now = new Date();
+
+ String token = underTest.encode(new JwtSession(USER_LOGIN, 20 * 60));
+
+ Claims claims = underTest.decode(token).get();
+ assertThat(claims.getId()).isNotEmpty();
+ assertThat(claims.getSubject()).isEqualTo(USER_LOGIN);
+ assertThat(claims.getExpiration()).isNotNull();
+ assertThat(claims.getIssuedAt()).isNotNull();
+ // Check expiration date it set to more than 19 minutes in the futur
+ assertThat(claims.getExpiration()).isAfterOrEqualsTo(new Date(now.getTime() + 19 * 60 * 1000));
+ }
+
+ @Test
+ public void return_no_token_when_expiration_date_is_reached() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = Jwts.builder()
+ .setId("123")
+ .setIssuedAt(new Date(system2.now()))
+ .setExpiration(new Date(system2.now()))
+ .signWith(SignatureAlgorithm.HS256, decodeSecretKey(SECRET_KEY))
+ .compact();
+
+ assertThat(underTest.decode(token)).isEmpty();
+ }
+
+ @Test
+ public void return_no_token_when_secret_key_has_changed() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = Jwts.builder()
+ .setId("123")
+ .setSubject(USER_LOGIN)
+ .setIssuedAt(new Date(system2.now()))
+ .setExpiration(new Date(system2.now() + 20 * 60 * 1000))
+ .signWith(SignatureAlgorithm.HS256, decodeSecretKey("LyWgHktP0FuHB2K+kMs3KWMCJyFHVZDdDSqpIxAMVaQ="))
+ .compact();
+
+ assertThat(underTest.decode(token)).isEmpty();
+ }
+
+ @Test
+ public void fail_to_decode_token_when_no_id() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = Jwts.builder()
+ .setSubject(USER_LOGIN)
+ .setIssuer("sonarqube")
+ .setIssuedAt(new Date(system2.now()))
+ .setExpiration(new Date(system2.now() + 20 * 60 * 1000))
+ .signWith(SignatureAlgorithm.HS256, decodeSecretKey(SECRET_KEY))
+ .compact();
+
+ expectedException.expect(UnauthorizedException.class);
+ expectedException.expectMessage("Token id hasn't been found");
+ underTest.decode(token);
+ }
+
+ @Test
+ public void fail_to_decode_token_when_no_subject() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = Jwts.builder()
+ .setId("123")
+ .setIssuer("sonarqube")
+ .setIssuedAt(new Date(system2.now()))
+ .setExpiration(new Date(system2.now() + 20 * 60 * 1000))
+ .signWith(SignatureAlgorithm.HS256, decodeSecretKey(SECRET_KEY))
+ .compact();
+
+ expectedException.expect(UnauthorizedException.class);
+ expectedException.expectMessage("Token subject hasn't been found");
+ underTest.decode(token);
+ }
+
+ @Test
+ public void fail_to_decode_token_when_no_expiration_date() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = Jwts.builder()
+ .setId("123")
+ .setIssuer("sonarqube")
+ .setSubject(USER_LOGIN)
+ .setIssuedAt(new Date(system2.now()))
+ .signWith(SignatureAlgorithm.HS256, decodeSecretKey(SECRET_KEY))
+ .compact();
+
+ expectedException.expect(UnauthorizedException.class);
+ expectedException.expectMessage("Token expiration date hasn't been found");
+ underTest.decode(token);
+ }
+
+ @Test
+ public void fail_to_decode_token_when_no_creation_date() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ String token = Jwts.builder()
+ .setId("123")
+ .setSubject(USER_LOGIN)
+ .setExpiration(new Date(system2.now() + 20 * 60 * 1000))
+ .signWith(SignatureAlgorithm.HS256, decodeSecretKey(SECRET_KEY))
+ .compact();
+
+ expectedException.expect(UnauthorizedException.class);
+ expectedException.expectMessage("Token creation date hasn't been found");
+ underTest.decode(token);
+ }
+
+ @Test
+ public void generate_new_secret_key_in_start() throws Exception {
+ settings.setProperty("sonar.jwt.base64hs256secretKey", (String) null);
+
+ underTest.start();
+
+ assertThat(settings.getString("sonar.auth.jwtBase64Hs256Secret")).isNotEmpty();
+ }
+
+ @Test
+ public void does_not_generate_new_secret_key_in_start_if_already_exists() throws Exception {
+ setDefaultSecretKey();
+
+ underTest.start();
+
+ assertThat(settings.getString("sonar.auth.jwtBase64Hs256Secret")).isEqualTo(SECRET_KEY);
+ }
+
+ @Test
+ public void refresh_token() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+
+ Date now = new Date();
+ Date createdAt = DateUtils.parseDate("2016-01-01");
+ // Expired in 10 minutes
+ Date expiredAt = new Date(now.getTime() + 10 * 60 * 1000);
+ Claims token = new DefaultClaims()
+ .setId("id")
+ .setSubject("subject")
+ .setIssuer("sonarqube")
+ .setIssuedAt(createdAt)
+ .setExpiration(expiredAt);
+ token.put("key", "value");
+
+ // Refresh the token with a higher expiration time
+ String encodedToken = underTest.refresh(token, 20 * 60);
+
+ Claims result = underTest.decode(encodedToken).get();
+ assertThat(result.getId()).isEqualTo("id");
+ assertThat(result.getSubject()).isEqualTo("subject");
+ assertThat(result.getIssuer()).isEqualTo("sonarqube");
+ assertThat(result.getIssuedAt()).isEqualTo(createdAt);
+ assertThat(result.get("key")).isEqualTo("value");
+ // Expiration date has been changed
+ assertThat(result.getExpiration()).isNotEqualTo(expiredAt)
+ .isAfterOrEqualsTo(new Date(now.getTime() + 19 * 1000));
+ }
+
+ @Test
+ public void refresh_token_generate_a_new_hash() throws Exception {
+ setDefaultSecretKey();
+ underTest.start();
+ String token = underTest.encode(new JwtSession(USER_LOGIN, 30));
+ Optional<Claims> claims = underTest.decode(token);
+
+ String newToken = underTest.refresh(claims.get(), 45);
+
+ assertThat(newToken).isNotEqualTo(token);
+ }
+
+ @Test
+ public void encode_fail_when_not_started() throws Exception {
+ expectedException.expect(NullPointerException.class);
+ expectedException.expectMessage("org.sonar.server.authentication.JwtSerializer not started");
+
+ underTest.encode(new JwtSession(USER_LOGIN, 10));
+ }
+
+ @Test
+ public void decode_fail_when_not_started() throws Exception {
+ expectedException.expect(NullPointerException.class);
+ expectedException.expectMessage("org.sonar.server.authentication.JwtSerializer not started");
+
+ underTest.decode("token");
+ }
+
+ @Test
+ public void refresh_fail_when_not_started() throws Exception {
+ expectedException.expect(NullPointerException.class);
+ expectedException.expectMessage("org.sonar.server.authentication.JwtSerializer not started");
+
+ underTest.refresh(new DefaultClaims(), 10);
+ }
+
+ private void setDefaultSecretKey() {
+ settings.setProperty("sonar.auth.jwtBase64Hs256Secret", SECRET_KEY);
+ }
+
+ private SecretKey decodeSecretKey(String encodedKey) {
+ byte[] decodedKey = Base64.getDecoder().decode(encodedKey);
+ return new SecretKeySpec(decodedKey, 0, decodedKey.length, SignatureAlgorithm.HS256.getJcaName());
+ }
+}
diff --git a/server/sonar-server/src/test/java/org/sonar/server/authentication/ValidateJwtTokenFilterTest.java b/server/sonar-server/src/test/java/org/sonar/server/authentication/ValidateJwtTokenFilterTest.java
new file mode 100644
index 00000000000..83ce54c52aa
--- /dev/null
+++ b/server/sonar-server/src/test/java/org/sonar/server/authentication/ValidateJwtTokenFilterTest.java
@@ -0,0 +1,76 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2016 SonarSource SA
+ * mailto:contact AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+package org.sonar.server.authentication;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.verifyZeroInteractions;
+import static org.mockito.Mockito.when;
+
+import javax.servlet.FilterChain;
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+import org.junit.Before;
+import org.junit.Test;
+import org.sonar.server.exceptions.UnauthorizedException;
+
+public class ValidateJwtTokenFilterTest {
+
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ HttpServletResponse response = mock(HttpServletResponse.class);
+ FilterChain chain = mock(FilterChain.class);
+
+ JwtHttpHandler jwtHttpHandler = mock(JwtHttpHandler.class);
+
+ ValidateJwtTokenFilter underTest = new ValidateJwtTokenFilter(jwtHttpHandler);
+
+ @Before
+ public void setUp() throws Exception {
+ when(request.getContextPath()).thenReturn("");
+ when(request.getRequestURI()).thenReturn("/test");
+ }
+
+ @Test
+ public void do_get_pattern() throws Exception {
+ assertThat(underTest.doGetPattern().getUrl()).isEqualTo("/*");
+ }
+
+ @Test
+ public void validate_session() throws Exception {
+ underTest.doFilter(request, response, chain);
+
+ verify(jwtHttpHandler).validateToken(request, response);
+ verify(chain).doFilter(request, response);
+ }
+
+ @Test
+ public void return_code_401_when_invalid_token_exception() throws Exception {
+ doThrow(new UnauthorizedException("invalid token")).when(jwtHttpHandler).validateToken(request, response);
+
+ underTest.doFilter(request, response, chain);
+
+ verify(response).setStatus(401);
+ verifyZeroInteractions(chain);
+ }
+
+}
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb
index 6916ab175c6..6aa2110123a 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb
@@ -45,6 +45,7 @@ class SessionsController < ApplicationController
self.current_user.remember_me
cookies[:auth_token] = { :value => self.current_user.remember_token , :expires => self.current_user.remember_token_expires_at, :http_only => true }
end
+ set_user_session
redirect_back_or_default(home_url)
else
render_unauthenticated
@@ -60,6 +61,7 @@ class SessionsController < ApplicationController
self.current_user.forget_me
end
cookies.delete :auth_token
+ cookies.delete 'JWT-SESSION'
flash[:notice]=message('session.flash_notice.logged_out')
redirect_to(home_path)
reset_session
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb b/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb
index ad49b055c13..3a911f78f74 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb
+++ b/server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb
@@ -8,7 +8,7 @@ module AuthenticatedSystem
# Accesses the current user from the session.
# Future calls avoid the database because nil is not equal to false.
def current_user
- @current_user ||= (login_from_session || login_from_basic_auth || login_from_cookie) unless @current_user == false
+ @current_user ||= (login_from_session || login_from_basic_auth) unless @current_user == false
end
# Store the given user id in the session.
diff --git a/server/sonar-web/src/main/webapp/WEB-INF/web.xml b/server/sonar-web/src/main/webapp/WEB-INF/web.xml
index 504ee37e03c..d4402205359 100644
--- a/server/sonar-web/src/main/webapp/WEB-INF/web.xml
+++ b/server/sonar-web/src/main/webapp/WEB-INF/web.xml
@@ -42,6 +42,11 @@
<param-name>addsHtmlToPathInfo</param-name>
<param-value>false</param-value>
</init-param>
+ <!--Do not reset unhandled response in order to be able to add cookie in java servlet -->
+ <init-param>
+ <param-name>resetUnhandledResponse</param-name>
+ <param-value>false</param-value>
+ </init-param>
</filter>
<filter>
<filter-name>SecurityFilter</filter-name>