]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-7713 Use JWT session
authorJulien Lancelot <julien.lancelot@sonarsource.com>
Tue, 14 Jun 2016 09:27:10 +0000 (11:27 +0200)
committerJulien Lancelot <julien.lancelot@sonarsource.com>
Wed, 15 Jun 2016 09:08:36 +0000 (11:08 +0200)
16 files changed:
pom.xml
server/sonar-server/pom.xml
server/sonar-server/src/main/java/org/sonar/server/authentication/AuthenticationModule.java
server/sonar-server/src/main/java/org/sonar/server/authentication/CookieUtils.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/authentication/CsrfVerifier.java
server/sonar-server/src/main/java/org/sonar/server/authentication/GenerateJwtTokenFilter.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/authentication/JwtSerializer.java [new file with mode: 0644]
server/sonar-server/src/main/java/org/sonar/server/authentication/ValidateJwtTokenFilter.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/authentication/GenerateJwtTokenFilterTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/authentication/JwtHttpHandlerTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/authentication/JwtSerializerTest.java [new file with mode: 0644]
server/sonar-server/src/test/java/org/sonar/server/authentication/ValidateJwtTokenFilterTest.java [new file with mode: 0644]
server/sonar-web/src/main/webapp/WEB-INF/app/controllers/sessions_controller.rb
server/sonar-web/src/main/webapp/WEB-INF/lib/authenticated_system.rb
server/sonar-web/src/main/webapp/WEB-INF/web.xml

diff --git a/pom.xml b/pom.xml
index c7bc0bce6d93d6930d4bb778f7f20dc938ecafe5..794d325883aa75e782c0452267bef665a3c39661 100644 (file)
--- 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>
           </exclusion>
         </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>
index 61954cd639559ae866019536324d13e7b7b5f31f..8fdd868fec415e0bf025f71af76c5c6a9f7fcff9 100644 (file)
       <groupId>${project.groupId}</groupId>
       <artifactId>sonar-plugin-bridge</artifactId>
     </dependency>
+    <dependency>
+      <groupId>io.jsonwebtoken</groupId>
+      <artifactId>jjwt</artifactId>
+    </dependency>
 
     <!-- unit tests -->
     <dependency>
index 2ecebed37bdeffcf0c0998395ce64dc65517b2ee..2ad26f657efc860a3546ae61e4c398231ac9e41c 100644 (file)
@@ -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 (file)
index 0000000..8814772
--- /dev/null
@@ -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();
+  }
+}
index 20ea1417e2328596ca9c2b2ea44c471e82633c4c..60ccd9651846965df3cdacd9070f564a1b347876 100644 (file)
  */
 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 (file)
index 0000000..24f206b
--- /dev/null
@@ -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 (file)
index 0000000..30c1567
--- /dev/null
@@ -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 (file)
index 0000000..189ee6a
--- /dev/null
@@ -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 (file)
index 0000000..3403601
--- /dev/null
@@ -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 (file)
index 0000000..e893913
--- /dev/null
@@ -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 (file)
index 0000000..fc3efd8
--- /dev/null
@@ -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 (file)
index 0000000..022edfc
--- /dev/null
@@ -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 (file)
index 0000000..83ce54c
--- /dev/null
@@ -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);
+  }
+
+}
index 6916ab175c6a300f8721ee9429a62b2f6aaf97f0..6aa2110123ad157098c982573bf2e19a27556e22 100644 (file)
@@ -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
index ad49b055c13659238311a9e48b865a3389d4d613..3a911f78f7479518b50b0bdd9e24f48c730225ce 100644 (file)
@@ -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.
index 504ee37e03c95a97ce2c6fd3b443db54e0295094..d4402205359c1d0521f29a924ec2d46093432fff 100644 (file)
       <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>