From 6f49a9466aaadfad0073c571e023436a9f96bc73 Mon Sep 17 00:00:00 2001 From: Julien Lancelot Date: Tue, 14 Jun 2016 11:27:10 +0200 Subject: [PATCH] SONAR-7713 Use JWT session --- pom.xml | 21 ++ server/sonar-server/pom.xml | 4 + .../authentication/AuthenticationModule.java | 6 +- .../server/authentication/CookieUtils.java | 43 +++ .../server/authentication/CsrfVerifier.java | 30 +- .../GenerateJwtTokenFilter.java | 141 ++++++++ .../server/authentication/JwtHttpHandler.java | 165 ++++++++++ .../server/authentication/JwtSerializer.java | 175 ++++++++++ .../ValidateJwtTokenFilter.java | 72 ++++ .../GenerateJwtTokenFilterTest.java | 105 ++++++ .../authentication/JwtHttpHandlerTest.java | 304 +++++++++++++++++ .../authentication/JwtSerializerTest.java | 309 ++++++++++++++++++ .../ValidateJwtTokenFilterTest.java | 76 +++++ .../app/controllers/sessions_controller.rb | 2 + .../WEB-INF/lib/authenticated_system.rb | 2 +- .../sonar-web/src/main/webapp/WEB-INF/web.xml | 5 + 16 files changed, 1442 insertions(+), 18 deletions(-) create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/CookieUtils.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/GenerateJwtTokenFilter.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/JwtHttpHandler.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/JwtSerializer.java create mode 100644 server/sonar-server/src/main/java/org/sonar/server/authentication/ValidateJwtTokenFilter.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/GenerateJwtTokenFilterTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/JwtHttpHandlerTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/JwtSerializerTest.java create mode 100644 server/sonar-server/src/test/java/org/sonar/server/authentication/ValidateJwtTokenFilterTest.java diff --git a/pom.xml b/pom.xml index c7bc0bce6d9..794d325883a 100644 --- a/pom.xml +++ b/pom.xml @@ -68,6 +68,7 @@ 2.3.3 3.11 2.6.0 + 2.6.6 3.0.0-beta-2 ${settings.localRepository}/com/google/protobuf/protoc/${protobuf.version}/protoc-${protobuf.version}-${os.detected.classifier}.exe @@ -643,6 +644,26 @@ + + io.jsonwebtoken + jjwt + 0.6.0 + + + com.fasterxml.jackson.core + jackson-core + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + + + com.fasterxml.jackson.core + jackson-annotations + ${jackson.version} + org.mybatis mybatis 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 @@ ${project.groupId} sonar-plugin-bridge + + io.jsonwebtoken + jjwt + 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 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 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 + * + * 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 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 = 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 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 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 entry : jwtSession.getProperties().entrySet()) { + jwtBuilder.claim(entry.getKey(), entry.getValue()); + } + return jwtBuilder.compact(); + } + + Optional 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 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 properties; + + JwtSession(String userLogin, int expirationTimeInSeconds) { + this(userLogin, expirationTimeInSeconds, Collections.emptyMap()); + } + + JwtSession(String userLogin, int expirationTimeInSeconds, Map 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 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 cookieArgumentCaptor = ArgumentCaptor.forClass(Cookie.class); + ArgumentCaptor 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 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 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 = 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 @@ addsHtmlToPathInfo false + + + resetUnhandledResponse + false + SecurityFilter -- 2.39.5