This is an automated email from the ASF dual-hosted git repository.
riemer pushed a commit to branch dev
in repository https://gitbox.apache.org/repos/asf/streampipes.git
The following commit(s) were added to refs/heads/dev by this push:
new 3bcccda16d feat: Support refresh tokens (#4231)
3bcccda16d is described below
commit 3bcccda16dd6d307a1e0dc82d7e13e0493629073
Author: Dominik Riemer <[email protected]>
AuthorDate: Fri Mar 6 09:39:58 2026 +0100
feat: Support refresh tokens (#4231)
---
.../model/client/user/LoginRequest.java | 4 +-
.../model/client/user/RefreshToken.java | 157 ++++++++++++++++++
.../manager/setup/design/UserDesignDocument.java | 16 ++
.../streampipes/rest/impl/Authentication.java | 184 +++++++++++++++++++--
.../service/core/UnauthenticatedInterfaces.java | 2 +
.../core/migrations/AvailableMigrations.java | 4 +-
.../v099/AddRefreshTokenViewsMigration.java | 73 ++++++++
...CookieOAuth2AuthorizationRequestRepository.java | 35 ++--
.../oauth2/OAuth2AuthenticationSuccessHandler.java | 138 +++++++++++-----
.../storage/api/core/INoSqlStorage.java | 3 +
.../storage/api/user/IRefreshTokenStorage.java | 12 +-
.../storage/couchdb/CouchDbStorageManager.java | 7 +
.../couchdb/impl/user/RefreshTokenStorageImpl.java | 63 +++++++
.../management/service/RefreshTokenService.java | 132 +++++++++++++++
.../_guards/auth.can-activate-children.guard.ts | 20 +--
ui/src/app/_guards/auth.can-activate.guard.ts | 12 +-
.../login/components/login/login.component.html | 3 +
.../app/login/components/login/login.component.ts | 8 +-
ui/src/app/login/services/login.service.ts | 19 ++-
ui/src/app/services/auth.service.ts | 94 +++++++++--
20 files changed, 868 insertions(+), 118 deletions(-)
diff --git
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
index 878d4d0ce9..6fe5652d6f 100644
---
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
+++
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
@@ -18,5 +18,7 @@
package org.apache.streampipes.model.client.user;
-public record LoginRequest(String username, String password) {
+public record LoginRequest(String username,
+ String password,
+ boolean rememberMe) {
}
diff --git
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/RefreshToken.java
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/RefreshToken.java
new file mode 100644
index 0000000000..ed75082748
--- /dev/null
+++
b/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/RefreshToken.java
@@ -0,0 +1,157 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package org.apache.streampipes.model.client.user;
+
+import org.apache.streampipes.model.shared.api.Storable;
+
+import com.fasterxml.jackson.annotation.JsonIgnore;
+import com.google.gson.annotations.SerializedName;
+
+public class RefreshToken implements Storable {
+
+ @SerializedName("_id")
+ private String tokenId;
+
+ @SerializedName("_rev")
+ private String rev;
+
+ // This field should be called $type since this is the identifier used in
the CouchDB view
+ @SerializedName("$type")
+ @JsonIgnore
+ private String type = "refresh-token";
+
+ private String principalId;
+
+ @JsonIgnore
+ private String hashedToken;
+
+ private long createdAtMillis;
+ private long expiresAtMillis;
+ private Long revokedAtMillis;
+ private String replacedByTokenId;
+ private boolean rememberMe;
+
+ public RefreshToken() {
+ }
+
+ public static RefreshToken create(String tokenId,
+ String principalId,
+ String hashedToken,
+ long createdAtMillis,
+ long expiresAtMillis,
+ boolean rememberMe) {
+ RefreshToken token = new RefreshToken();
+ token.setTokenId(tokenId);
+ token.setPrincipalId(principalId);
+ token.setHashedToken(hashedToken);
+ token.setCreatedAtMillis(createdAtMillis);
+ token.setExpiresAtMillis(expiresAtMillis);
+ token.setRememberMe(rememberMe);
+ return token;
+ }
+
+ @Override
+ public String getElementId() {
+ return tokenId;
+ }
+
+ @Override
+ public void setElementId(String elementId) {
+ this.tokenId = elementId;
+ }
+
+ public String getTokenId() {
+ return tokenId;
+ }
+
+ public void setTokenId(String tokenId) {
+ this.tokenId = tokenId;
+ }
+
+ public String getRev() {
+ return rev;
+ }
+
+ public void setRev(String rev) {
+ this.rev = rev;
+ }
+
+ public String getType() {
+ return type;
+ }
+
+ public void setType(String type) {
+ this.type = type;
+ }
+
+ public String getPrincipalId() {
+ return principalId;
+ }
+
+ public void setPrincipalId(String principalId) {
+ this.principalId = principalId;
+ }
+
+ public String getHashedToken() {
+ return hashedToken;
+ }
+
+ public void setHashedToken(String hashedToken) {
+ this.hashedToken = hashedToken;
+ }
+
+ public long getCreatedAtMillis() {
+ return createdAtMillis;
+ }
+
+ public void setCreatedAtMillis(long createdAtMillis) {
+ this.createdAtMillis = createdAtMillis;
+ }
+
+ public long getExpiresAtMillis() {
+ return expiresAtMillis;
+ }
+
+ public void setExpiresAtMillis(long expiresAtMillis) {
+ this.expiresAtMillis = expiresAtMillis;
+ }
+
+ public Long getRevokedAtMillis() {
+ return revokedAtMillis;
+ }
+
+ public void setRevokedAtMillis(Long revokedAtMillis) {
+ this.revokedAtMillis = revokedAtMillis;
+ }
+
+ public String getReplacedByTokenId() {
+ return replacedByTokenId;
+ }
+
+ public void setReplacedByTokenId(String replacedByTokenId) {
+ this.replacedByTokenId = replacedByTokenId;
+ }
+
+ public boolean isRememberMe() {
+ return rememberMe;
+ }
+
+ public void setRememberMe(boolean rememberMe) {
+ this.rememberMe = rememberMe;
+ }
+}
diff --git
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
index 4aa7176178..2ab4659efa 100644
---
a/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
+++
b/streampipes-pipeline-management/src/main/java/org/apache/streampipes/manager/setup/design/UserDesignDocument.java
@@ -37,6 +37,14 @@ public class UserDesignDocument {
public static final String PRIVILEGE_MAP_FUNCTION =
"function(doc) { if(doc.$type === 'privilege') { emit(doc._id, doc); }
}";
+ public static final String REFRESH_TOKEN_BY_HASH_KEY =
"refresh-token-by-hash";
+ public static final String REFRESH_TOKEN_BY_HASH_MAP_FUNCTION =
+ "function(doc) { if (doc.$type === 'refresh-token' && doc.hashedToken) {
emit(doc.hashedToken, doc); } }";
+
+ public static final String REFRESH_TOKEN_BY_USER_KEY =
"refresh-token-by-user";
+ public static final String REFRESH_TOKEN_BY_USER_MAP_FUNCTION =
+ "function(doc) { if (doc.$type === 'refresh-token' && doc.principalId) {
emit(doc.principalId, doc); } }";
+
public DesignDocument make() {
DesignDocument userDocument = prepareDocument("_design/users");
Map<String, DesignDocument.MapReduce> views = new HashMap<>();
@@ -81,6 +89,12 @@ public class UserDesignDocument {
DesignDocument.MapReduce privilegeFunction = new
DesignDocument.MapReduce();
privilegeFunction.setMap(PRIVILEGE_MAP_FUNCTION);
+ DesignDocument.MapReduce refreshTokenByHashFunction = new
DesignDocument.MapReduce();
+ refreshTokenByHashFunction.setMap(REFRESH_TOKEN_BY_HASH_MAP_FUNCTION);
+
+ DesignDocument.MapReduce refreshTokenByUserFunction = new
DesignDocument.MapReduce();
+ refreshTokenByUserFunction.setMap(REFRESH_TOKEN_BY_USER_MAP_FUNCTION);
+
views.put("password", passwordFunction);
views.put(USERNAME_KEY, usernameFunction);
views.put("groups", groupFunction);
@@ -92,6 +106,8 @@ public class UserDesignDocument {
views.put("password-recovery", passwordRecoveryFunction);
views.put(ROLE_KEY, roleFunction);
views.put("privilege", privilegeFunction);
+ views.put(REFRESH_TOKEN_BY_HASH_KEY, refreshTokenByHashFunction);
+ views.put(REFRESH_TOKEN_BY_USER_KEY, refreshTokenByUserFunction);
userDocument.setViews(views);
diff --git
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
index ac61cd1939..456a2aa9d6 100644
---
a/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
+++
b/streampipes-rest/src/main/java/org/apache/streampipes/rest/impl/Authentication.java
@@ -27,17 +27,19 @@ import org.apache.streampipes.model.client.user.Principal;
import org.apache.streampipes.model.client.user.UserAccount;
import org.apache.streampipes.model.client.user.UserRegistrationData;
import org.apache.streampipes.model.configuration.GeneralConfig;
-import org.apache.streampipes.model.message.ErrorMessage;
import org.apache.streampipes.model.message.NotificationType;
import org.apache.streampipes.model.message.Notifications;
import org.apache.streampipes.model.message.SuccessMessage;
import org.apache.streampipes.rest.core.base.impl.AbstractRestResource;
import org.apache.streampipes.rest.shared.exception.SpMessageException;
+import org.apache.streampipes.storage.management.StorageDispatcher;
import org.apache.streampipes.user.management.jwt.JwtTokenProvider;
import org.apache.streampipes.user.management.model.PrincipalUserDetails;
+import org.apache.streampipes.user.management.service.RefreshTokenService;
-import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseCookie;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
@@ -50,42 +52,104 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.concurrent.TimeUnit;
@RestController
@RequestMapping("/api/v2/auth")
public class Authentication extends AbstractRestResource {
- @Autowired
+ private static final String REFRESH_TOKEN_COOKIE = "sp-refresh-token";
+ private static final String ENCODED_REFRESH_TOKEN_PREFIX = "b64.";
+ private static final long MIN_REFRESH_COOKIE_SECONDS = 1;
+
AuthenticationManager authenticationManager;
+ public Authentication(AuthenticationManager authenticationManager) {
+ this.authenticationManager = authenticationManager;
+ }
+
@PostMapping(
path = "/login",
produces = org.springframework.http.MediaType.APPLICATION_JSON_VALUE,
consumes = org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
- public ResponseEntity<?> doLogin(@RequestBody LoginRequest login) {
+ public ResponseEntity<?> doLogin(@RequestBody LoginRequest login,
+ HttpServletRequest request,
+ HttpServletResponse response) {
try {
org.springframework.security.core.Authentication authentication =
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(login.username(),
login.password()));
SecurityContextHolder.getContext().setAuthentication(authentication);
- return processAuth(authentication);
+ return processAuth(authentication, login.rememberMe(), request,
response);
} catch (BadCredentialsException e) {
return unauthorized();
}
}
- @GetMapping(
- path = "/token/renew",
+ @PostMapping(
+ path = "/token/refresh",
produces = org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
- public ResponseEntity<?> doLogin() {
- try {
- org.springframework.security.core.Authentication auth =
SecurityContextHolder.getContext().getAuthentication();
- return processAuth(auth);
- } catch (BadCredentialsException e) {
- return ok(new
ErrorMessage(NotificationType.LOGIN_FAILED.uiNotification()));
+ public ResponseEntity<?> refreshToken(HttpServletRequest request,
+ HttpServletResponse response) {
+ String existingToken = getRefreshTokenFromRequest(request);
+
+ if (existingToken == null) {
+ clearRefreshCookie(request, response);
+ return unauthorized();
+ }
+
+ var issuedRefreshToken = new
RefreshTokenService().rotateRefreshToken(existingToken);
+
+ if (issuedRefreshToken == null) {
+ clearRefreshCookie(request, response);
+ return unauthorized();
+ }
+
+ var principal = StorageDispatcher.INSTANCE
+ .getNoSqlStore()
+ .getUserStorageAPI()
+ .getUserById(issuedRefreshToken.principalId());
+
+ if (!(principal instanceof UserAccount userAccount)) {
+ clearRefreshCookie(request, response);
+ return unauthorized();
}
+
+ setRefreshCookie(request, response, issuedRefreshToken);
+
+ String jwt = new JwtTokenProvider().createToken(userAccount);
+ return ok(new JwtAuthenticationResponse(jwt));
+ }
+
+ @PostMapping(
+ path = "/logout",
+ produces = org.springframework.http.MediaType.APPLICATION_JSON_VALUE)
+ public ResponseEntity<?> logout(HttpServletRequest request,
+ HttpServletResponse response) {
+ RefreshTokenService refreshTokenService = new RefreshTokenService();
+ String existingToken = getRefreshTokenFromRequest(request);
+
+ if (existingToken != null) {
+ refreshTokenService.deleteAllRefreshTokensByRawToken(existingToken);
+ } else {
+ var authentication =
SecurityContextHolder.getContext().getAuthentication();
+ if (authentication != null && authentication.getPrincipal() instanceof
PrincipalUserDetails<?> principal) {
+
refreshTokenService.deleteAllRefreshTokens(principal.getDetails().getPrincipalId());
+ }
+ }
+
+ clearRefreshCookie(request, response);
+ SecurityContextHolder.clearContext();
+
+ return ok();
}
@PostMapping(
@@ -153,10 +217,17 @@ public class Authentication extends AbstractRestResource {
return ok(response);
}
- private ResponseEntity<JwtAuthenticationResponse>
processAuth(org.springframework.security.core.Authentication auth) {
+ private ResponseEntity<JwtAuthenticationResponse>
processAuth(org.springframework.security.core.Authentication auth,
+ boolean
rememberMe,
+
HttpServletRequest request,
+
HttpServletResponse response) {
Principal principal = ((PrincipalUserDetails<?>)
auth.getPrincipal()).getDetails();
if (principal instanceof UserAccount) {
JwtAuthenticationResponse tokenResp = makeJwtResponse(auth);
+ if (request != null && response != null) {
+ var issuedRefreshToken = new
RefreshTokenService().issueRefreshToken(principal.getPrincipalId(), rememberMe);
+ setRefreshCookie(request, response, issuedRefreshToken);
+ }
((UserAccount)
principal).setLastLoginAtMillis(System.currentTimeMillis());
getSpResourceManager().manageUsers().updateUser(principal);
return ok(tokenResp);
@@ -170,6 +241,91 @@ public class Authentication extends AbstractRestResource {
return new JwtAuthenticationResponse(jwt);
}
+ private void setRefreshCookie(HttpServletRequest request,
+ HttpServletResponse response,
+ RefreshTokenService.IssuedRefreshToken
issuedRefreshToken) {
+ long maxAgeSeconds = TimeUnit.MILLISECONDS.toSeconds(
+ Math.max(
+ MIN_REFRESH_COOKIE_SECONDS,
+ issuedRefreshToken.expiresAtMillis() - System.currentTimeMillis()
+ )
+ );
+
+ ResponseCookie.ResponseCookieBuilder cookieBuilder = ResponseCookie
+ .from(REFRESH_TOKEN_COOKIE,
encodeCookieTokenValue(issuedRefreshToken.rawToken()))
+ .httpOnly(true)
+ .secure(isSecureRequest(request))
+ .path(refreshCookiePath(request))
+ .sameSite("Lax");
+
+ if (issuedRefreshToken.rememberMe()) {
+ cookieBuilder.maxAge(maxAgeSeconds);
+ }
+
+ response.addHeader(HttpHeaders.SET_COOKIE,
cookieBuilder.build().toString());
+ }
+
+ private void clearRefreshCookie(HttpServletRequest request,
+ HttpServletResponse response) {
+ ResponseCookie cookie = ResponseCookie
+ .from(REFRESH_TOKEN_COOKIE, "")
+ .httpOnly(true)
+ .secure(isSecureRequest(request))
+ .path(refreshCookiePath(request))
+ .maxAge(0)
+ .sameSite("Lax")
+ .build();
+
+ response.addHeader(HttpHeaders.SET_COOKIE, cookie.toString());
+ }
+
+ private String getRefreshTokenFromRequest(HttpServletRequest request) {
+ Cookie[] cookies = request.getCookies();
+
+ if (cookies == null) {
+ return null;
+ }
+
+ for (Cookie cookie : cookies) {
+ if (REFRESH_TOKEN_COOKIE.equals(cookie.getName())) {
+ return decodeCookieTokenValue(cookie.getValue());
+ }
+ }
+
+ return null;
+ }
+
+ private String refreshCookiePath(HttpServletRequest request) {
+ var contextPath = request.getContextPath();
+ return (contextPath == null ? "" : contextPath) + "/api/v2/auth";
+ }
+
+ private boolean isSecureRequest(HttpServletRequest request) {
+ String forwardedProto = request.getHeader("X-Forwarded-Proto");
+ return request.isSecure() || "https".equalsIgnoreCase(forwardedProto);
+ }
+
+ private String encodeCookieTokenValue(String rawToken) {
+ return ENCODED_REFRESH_TOKEN_PREFIX + Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(rawToken.getBytes(StandardCharsets.UTF_8));
+ }
+
+ private String decodeCookieTokenValue(String cookieValue) {
+ if (!cookieValue.startsWith(ENCODED_REFRESH_TOKEN_PREFIX)) {
+ return cookieValue;
+ }
+
+ try {
+ byte[] decoded = Base64.getUrlDecoder().decode(
+ cookieValue.substring(ENCODED_REFRESH_TOKEN_PREFIX.length())
+ );
+ return new String(decoded, StandardCharsets.UTF_8);
+ } catch (IllegalArgumentException e) {
+ return null;
+ }
+ }
+
private UiOAuthSettings makeOAuthSettings() {
var env = Environments.getEnvironment();
var oAuthConfigs = env.getOAuthConfigurations();
diff --git
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
index 1653f93e1f..774de0c1ea 100644
---
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
+++
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/UnauthenticatedInterfaces.java
@@ -27,8 +27,10 @@ public class UnauthenticatedInterfaces {
"/api/svchealth/*",
"/api/v2/setup/configured",
"/api/v2/auth/login",
+ "/api/v2/auth/logout",
"/api/v2/auth/register",
"/api/v2/auth/settings",
+ "/api/v2/auth/token/refresh",
"/api/v2/auth/restore/*",
"/api/v2/restore-password/*",
"/api/v2/activate-account/*",
diff --git
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
index b09d4d7ec6..b981830201 100644
---
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
+++
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/AvailableMigrations.java
@@ -31,6 +31,7 @@ import
org.apache.streampipes.service.core.migrations.v0980.ModifyAssetLinkTypes
import
org.apache.streampipes.service.core.migrations.v0980.ModifyAssetLinksMigration;
import
org.apache.streampipes.service.core.migrations.v099.AddAssetManagementViewMigration;
import
org.apache.streampipes.service.core.migrations.v099.AddFunctionStateViewMigration;
+import
org.apache.streampipes.service.core.migrations.v099.AddRefreshTokenViewsMigration;
import
org.apache.streampipes.service.core.migrations.v099.AddScriptTemplateViewMigration;
import
org.apache.streampipes.service.core.migrations.v099.ComputeCertificateThumbprintMigration;
import
org.apache.streampipes.service.core.migrations.v099.CreateAssetPermissionMigration;
@@ -82,7 +83,8 @@ public class AvailableMigrations {
new MigrateAdaptersToUseScript(),
new ModifyAssetLinkIconMigration(),
new RemoveDuplicatedAssetPermissions(),
- new AddFunctionStateViewMigration()
+ new AddFunctionStateViewMigration(),
+ new AddRefreshTokenViewsMigration()
);
}
}
diff --git
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/v099/AddRefreshTokenViewsMigration.java
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/v099/AddRefreshTokenViewsMigration.java
new file mode 100644
index 0000000000..773f9ff653
--- /dev/null
+++
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/migrations/v099/AddRefreshTokenViewsMigration.java
@@ -0,0 +1,73 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package org.apache.streampipes.service.core.migrations.v099;
+
+import org.apache.streampipes.manager.setup.design.UserDesignDocument;
+import org.apache.streampipes.service.core.migrations.Migration;
+import org.apache.streampipes.storage.couchdb.utils.Utils;
+
+import org.lightcouch.DesignDocument;
+import org.lightcouch.Response;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Map;
+
+public class AddRefreshTokenViewsMigration implements Migration {
+
+ private static final String DOC_NAME = "_design/users";
+ private static final Logger LOG =
LoggerFactory.getLogger(AddRefreshTokenViewsMigration.class);
+
+ @Override
+ public boolean shouldExecute() {
+ var designDoc = Utils.getCouchDbUserClient().design().getFromDb(DOC_NAME);
+ var views = designDoc.getViews();
+
+ return !containsView(
+ views,
+ UserDesignDocument.REFRESH_TOKEN_BY_HASH_KEY,
+ UserDesignDocument.REFRESH_TOKEN_BY_HASH_MAP_FUNCTION
+ ) || !containsView(
+ views,
+ UserDesignDocument.REFRESH_TOKEN_BY_USER_KEY,
+ UserDesignDocument.REFRESH_TOKEN_BY_USER_MAP_FUNCTION
+ );
+ }
+
+ @Override
+ public void executeMigration() throws IOException {
+ var userDocument = new UserDesignDocument().make();
+ Response resp =
Utils.getCouchDbUserClient().design().synchronizeWithDb(userDocument);
+
+ if (resp.getError() != null) {
+ LOG.warn("Could not update user design document with reason {}",
resp.getReason());
+ }
+ }
+
+ @Override
+ public String getDescription() {
+ return "Add refresh token views to user database";
+ }
+
+ private boolean containsView(Map<String, DesignDocument.MapReduce> views,
+ String viewKey,
+ String mapFunction) {
+ return views.containsKey(viewKey) &&
mapFunction.equals(views.get(viewKey).getMap());
+ }
+}
diff --git
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
index f68ef84e90..d442ddc053 100755
---
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
+++
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/HttpCookieOAuth2AuthorizationRequestRepository.java
@@ -33,9 +33,10 @@ import jakarta.servlet.http.HttpServletResponse;
public class HttpCookieOAuth2AuthorizationRequestRepository
implements AuthorizationRequestRepository<OAuth2AuthorizationRequest> {
- private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME =
"oauth2_auth_request";
- public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
- private static final int cookieExpireSeconds = 180;
+ private static final String OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME =
"oauth2_auth_request";
+ public static final String REDIRECT_URI_PARAM_COOKIE_NAME = "redirect_uri";
+ public static final String REMEMBER_ME_PARAM_COOKIE_NAME = "remember_me";
+ private static final int cookieExpireSeconds = 180;
@Override
public OAuth2AuthorizationRequest
loadAuthorizationRequest(HttpServletRequest request) {
@@ -63,11 +64,16 @@ public class HttpCookieOAuth2AuthorizationRequestRepository
cookieExpireSeconds
);
- String redirectUriAfterLogin =
request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
- if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
- CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME,
redirectUriAfterLogin, cookieExpireSeconds);
- }
- }
+ String redirectUriAfterLogin =
request.getParameter(REDIRECT_URI_PARAM_COOKIE_NAME);
+ if (StringUtils.isNotBlank(redirectUriAfterLogin)) {
+ CookieUtils.addCookie(response, REDIRECT_URI_PARAM_COOKIE_NAME,
redirectUriAfterLogin, cookieExpireSeconds);
+ }
+
+ String rememberMe = request.getParameter(REMEMBER_ME_PARAM_COOKIE_NAME);
+ if (StringUtils.isNotBlank(rememberMe)) {
+ CookieUtils.addCookie(response, REMEMBER_ME_PARAM_COOKIE_NAME,
rememberMe, cookieExpireSeconds);
+ }
+ }
@Override
public OAuth2AuthorizationRequest
removeAuthorizationRequest(HttpServletRequest request,
@@ -75,9 +81,10 @@ public class HttpCookieOAuth2AuthorizationRequestRepository
return this.loadAuthorizationRequest(request);
}
- public void removeAuthorizationRequestCookies(HttpServletRequest request,
- HttpServletResponse response) {
- CookieUtils.deleteCookie(request, response,
OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
- CookieUtils.deleteCookie(request, response,
REDIRECT_URI_PARAM_COOKIE_NAME);
- }
-}
+ public void removeAuthorizationRequestCookies(HttpServletRequest request,
+ HttpServletResponse response) {
+ CookieUtils.deleteCookie(request, response,
OAUTH2_AUTHORIZATION_REQUEST_COOKIE_NAME);
+ CookieUtils.deleteCookie(request, response,
REDIRECT_URI_PARAM_COOKIE_NAME);
+ CookieUtils.deleteCookie(request, response, REMEMBER_ME_PARAM_COOKIE_NAME);
+ }
+}
diff --git
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
index 43d058a992..36cfd187ab 100755
---
a/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
+++
b/streampipes-service-core/src/main/java/org/apache/streampipes/service/core/oauth2/OAuth2AuthenticationSuccessHandler.java
@@ -18,31 +18,41 @@
package org.apache.streampipes.service.core.oauth2;
-import org.apache.streampipes.commons.environment.Environment;
-import org.apache.streampipes.commons.environment.Environments;
-import org.apache.streampipes.rest.shared.exception.BadRequestException;
-import org.apache.streampipes.service.core.oauth2.util.CookieUtils;
-import org.apache.streampipes.user.management.jwt.JwtTokenProvider;
-
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.security.core.Authentication;
-import
org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
-import org.springframework.stereotype.Component;
+import org.apache.streampipes.commons.environment.Environment;
+import org.apache.streampipes.commons.environment.Environments;
+import org.apache.streampipes.model.client.user.Principal;
+import org.apache.streampipes.rest.shared.exception.BadRequestException;
+import org.apache.streampipes.service.core.oauth2.util.CookieUtils;
+import org.apache.streampipes.user.management.jwt.JwtTokenProvider;
+import org.apache.streampipes.user.management.model.PrincipalUserDetails;
+import org.apache.streampipes.user.management.service.RefreshTokenService;
+
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.ResponseCookie;
+import org.springframework.security.core.Authentication;
+import
org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
+import org.springframework.stereotype.Component;
import jakarta.servlet.http.Cookie;
-import jakarta.servlet.http.HttpServletRequest;
-import jakarta.servlet.http.HttpServletResponse;
-
-import java.io.IOException;
-import java.net.URI;
-import java.util.Optional;
-
-import static
org.apache.streampipes.service.core.oauth2.HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME;
-
-@Component
-public class OAuth2AuthenticationSuccessHandler extends
SimpleUrlAuthenticationSuccessHandler {
-
- private final JwtTokenProvider tokenProvider;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Optional;
+import java.util.concurrent.TimeUnit;
+
+@Component
+public class OAuth2AuthenticationSuccessHandler extends
SimpleUrlAuthenticationSuccessHandler {
+
+ private static final String REFRESH_TOKEN_COOKIE = "sp-refresh-token";
+ private static final String ENCODED_REFRESH_TOKEN_PREFIX = "b64.";
+ private static final long MIN_REFRESH_COOKIE_SECONDS = 1;
+
+ private final JwtTokenProvider tokenProvider;
private final HttpCookieOAuth2AuthorizationRequestRepository
httpCookieOAuth2AuthorizationRequestRepository;
private final Environment env;
@@ -69,27 +79,77 @@ public class OAuth2AuthenticationSuccessHandler extends
SimpleUrlAuthenticationS
}
@Override
- protected String determineTargetUrl(HttpServletRequest request,
- HttpServletResponse response,
- Authentication authentication) {
- Optional<String> redirectUri = CookieUtils
- .getCookie(request, REDIRECT_URI_PARAM_COOKIE_NAME)
- .map(Cookie::getValue);
+ protected String determineTargetUrl(HttpServletRequest request,
+ HttpServletResponse response,
+ Authentication authentication) {
+ Optional<String> redirectUri = CookieUtils
+ .getCookie(request,
HttpCookieOAuth2AuthorizationRequestRepository.REDIRECT_URI_PARAM_COOKIE_NAME)
+ .map(Cookie::getValue);
if (redirectUri.isPresent() &&
!isAuthorizedRedirectUri(redirectUri.get())) {
throw new BadRequestException(
"Unauthorized redirect uri found - check the redirect uri in your
OAuth config"
);
- }
-
- String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
- String token = tokenProvider.createToken(authentication);
-
- return targetUrl + "?token=" + token;
- }
-
- protected void clearAuthenticationAttributes(HttpServletRequest request,
- HttpServletResponse response) {
+ }
+
+ String targetUrl = redirectUri.orElse(getDefaultTargetUrl());
+ boolean rememberMe = CookieUtils
+ .getCookie(request,
HttpCookieOAuth2AuthorizationRequestRepository.REMEMBER_ME_PARAM_COOKIE_NAME)
+ .map(Cookie::getValue)
+ .map(Boolean::parseBoolean)
+ .orElse(false);
+
+ Principal principal = ((PrincipalUserDetails<?>)
authentication.getPrincipal()).getDetails();
+ var refreshToken = new
RefreshTokenService().issueRefreshToken(principal.getPrincipalId(), rememberMe);
+ setRefreshCookie(request, response, refreshToken);
+
+ String token = tokenProvider.createToken(authentication);
+
+ return targetUrl + "?token=" + token;
+ }
+
+ private void setRefreshCookie(HttpServletRequest request,
+ HttpServletResponse response,
+ RefreshTokenService.IssuedRefreshToken
issuedRefreshToken) {
+ long maxAgeSeconds = TimeUnit.MILLISECONDS.toSeconds(
+ Math.max(
+ MIN_REFRESH_COOKIE_SECONDS,
+ issuedRefreshToken.expiresAtMillis() - System.currentTimeMillis()
+ )
+ );
+
+ ResponseCookie.ResponseCookieBuilder cookieBuilder = ResponseCookie
+ .from(REFRESH_TOKEN_COOKIE,
encodeCookieTokenValue(issuedRefreshToken.rawToken()))
+ .httpOnly(true)
+ .secure(isSecureRequest(request))
+ .path(refreshCookiePath(request))
+ .sameSite("Lax");
+
+ if (issuedRefreshToken.rememberMe()) {
+ cookieBuilder.maxAge(maxAgeSeconds);
+ }
+
+ response.addHeader(HttpHeaders.SET_COOKIE,
cookieBuilder.build().toString());
+ }
+
+ private String refreshCookiePath(HttpServletRequest request) {
+ var contextPath = request.getContextPath();
+ return (contextPath == null ? "" : contextPath) + "/api/v2/auth";
+ }
+
+ private boolean isSecureRequest(HttpServletRequest request) {
+ String forwardedProto = request.getHeader("X-Forwarded-Proto");
+ return request.isSecure() || "https".equalsIgnoreCase(forwardedProto);
+ }
+
+ private String encodeCookieTokenValue(String rawToken) {
+ return ENCODED_REFRESH_TOKEN_PREFIX + Base64.getUrlEncoder()
+ .withoutPadding()
+ .encodeToString(rawToken.getBytes(StandardCharsets.UTF_8));
+ }
+
+ protected void clearAuthenticationAttributes(HttpServletRequest request,
+ HttpServletResponse response) {
super.clearAuthenticationAttributes(request);
httpCookieOAuth2AuthorizationRequestRepository.removeAuthorizationRequestCookies(request,
response);
}
diff --git
a/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
index 2e659aa65f..3f7c1ca222 100644
---
a/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
+++
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/core/INoSqlStorage.java
@@ -42,6 +42,7 @@ import
org.apache.streampipes.storage.api.system.ITransformationScriptTemplateSt
import org.apache.streampipes.storage.api.user.IPasswordRecoveryTokenStorage;
import org.apache.streampipes.storage.api.user.IPermissionStorage;
import org.apache.streampipes.storage.api.user.IPrivilegeStorage;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
import org.apache.streampipes.storage.api.user.IRoleStorage;
import org.apache.streampipes.storage.api.user.IUserActivationTokenStorage;
import org.apache.streampipes.storage.api.user.IUserGroupStorage;
@@ -91,6 +92,8 @@ public interface INoSqlStorage {
IUserActivationTokenStorage getUserActivationTokenStorage();
+ IRefreshTokenStorage getRefreshTokenStorage();
+
IExtensionsServiceStorage getExtensionsServiceStorage();
IExtensionsServiceConfigurationStorage
getExtensionsServiceConfigurationStorage();
diff --git
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/user/IRefreshTokenStorage.java
similarity index 67%
copy from
streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
copy to
streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/user/IRefreshTokenStorage.java
index 878d4d0ce9..f5373e2877 100644
---
a/streampipes-model-client/src/main/java/org/apache/streampipes/model/client/user/LoginRequest.java
+++
b/streampipes-storage-api/src/main/java/org/apache/streampipes/storage/api/user/IRefreshTokenStorage.java
@@ -15,8 +15,16 @@
* limitations under the License.
*
*/
+package org.apache.streampipes.storage.api.user;
-package org.apache.streampipes.model.client.user;
+import org.apache.streampipes.model.client.user.RefreshToken;
+import org.apache.streampipes.storage.api.core.CRUDStorage;
-public record LoginRequest(String username, String password) {
+import java.util.List;
+
+public interface IRefreshTokenStorage extends CRUDStorage<RefreshToken> {
+
+ RefreshToken findByHashedToken(String hashedToken);
+
+ List<RefreshToken> findByPrincipalId(String principalId);
}
diff --git
a/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
index d7d07af389..a682385ac3 100644
---
a/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
+++
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/CouchDbStorageManager.java
@@ -44,6 +44,7 @@ import
org.apache.streampipes.storage.api.system.ITransformationScriptTemplateSt
import org.apache.streampipes.storage.api.user.IPasswordRecoveryTokenStorage;
import org.apache.streampipes.storage.api.user.IPermissionStorage;
import org.apache.streampipes.storage.api.user.IPrivilegeStorage;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
import org.apache.streampipes.storage.api.user.IRoleStorage;
import org.apache.streampipes.storage.api.user.IUserActivationTokenStorage;
import org.apache.streampipes.storage.api.user.IUserGroupStorage;
@@ -74,6 +75,7 @@ import
org.apache.streampipes.storage.couchdb.impl.system.TransformationScriptTe
import
org.apache.streampipes.storage.couchdb.impl.user.PasswordRecoveryTokenStorageImpl;
import org.apache.streampipes.storage.couchdb.impl.user.PermissionStorageImpl;
import org.apache.streampipes.storage.couchdb.impl.user.PrivilegeStorageImpl;
+import
org.apache.streampipes.storage.couchdb.impl.user.RefreshTokenStorageImpl;
import org.apache.streampipes.storage.couchdb.impl.user.RoleStorageImpl;
import
org.apache.streampipes.storage.couchdb.impl.user.UserActivationTokenStorageImpl;
import org.apache.streampipes.storage.couchdb.impl.user.UserGroupStorageImpl;
@@ -190,6 +192,11 @@ public class CouchDbStorageManager implements
INoSqlStorage {
return new UserActivationTokenStorageImpl();
}
+ @Override
+ public IRefreshTokenStorage getRefreshTokenStorage() {
+ return new RefreshTokenStorageImpl();
+ }
+
@Override
public IExtensionsServiceStorage getExtensionsServiceStorage() {
return new ExtensionsServiceStorageImpl();
diff --git
a/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/impl/user/RefreshTokenStorageImpl.java
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/impl/user/RefreshTokenStorageImpl.java
new file mode 100644
index 0000000000..1078a5b5ac
--- /dev/null
+++
b/streampipes-storage-couchdb/src/main/java/org/apache/streampipes/storage/couchdb/impl/user/RefreshTokenStorageImpl.java
@@ -0,0 +1,63 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package org.apache.streampipes.storage.couchdb.impl.user;
+
+import org.apache.streampipes.model.client.user.RefreshToken;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
+import org.apache.streampipes.storage.couchdb.impl.core.DefaultViewCrudStorage;
+import org.apache.streampipes.storage.couchdb.utils.Utils;
+
+import java.util.List;
+
+public class RefreshTokenStorageImpl extends
DefaultViewCrudStorage<RefreshToken>
+ implements IRefreshTokenStorage {
+
+ private static final String REFRESH_TOKEN_BY_HASH_VIEW =
"users/refresh-token-by-hash";
+ private static final String REFRESH_TOKEN_BY_PRINCIPAL_ID_VIEW =
"users/refresh-token-by-user";
+
+ public RefreshTokenStorageImpl() {
+ super(
+ Utils::getCouchDbUserClient,
+ RefreshToken.class,
+ REFRESH_TOKEN_BY_HASH_VIEW
+ );
+ }
+
+ @Override
+ public RefreshToken findByHashedToken(String hashedToken) {
+ return couchDbClientSupplier
+ .get()
+ .view(REFRESH_TOKEN_BY_HASH_VIEW)
+ .key(hashedToken)
+ .includeDocs(true)
+ .query(RefreshToken.class)
+ .stream()
+ .findFirst()
+ .orElse(null);
+ }
+
+ @Override
+ public List<RefreshToken> findByPrincipalId(String principalId) {
+ return couchDbClientSupplier
+ .get()
+ .view(REFRESH_TOKEN_BY_PRINCIPAL_ID_VIEW)
+ .key(principalId)
+ .includeDocs(true)
+ .query(RefreshToken.class);
+ }
+}
diff --git
a/streampipes-user-management/src/main/java/org/apache/streampipes/user/management/service/RefreshTokenService.java
b/streampipes-user-management/src/main/java/org/apache/streampipes/user/management/service/RefreshTokenService.java
new file mode 100644
index 0000000000..e380ce1f88
--- /dev/null
+++
b/streampipes-user-management/src/main/java/org/apache/streampipes/user/management/service/RefreshTokenService.java
@@ -0,0 +1,132 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+package org.apache.streampipes.user.management.service;
+
+import org.apache.streampipes.model.client.user.RefreshToken;
+import org.apache.streampipes.storage.api.user.IRefreshTokenStorage;
+import org.apache.streampipes.storage.management.StorageDispatcher;
+import org.apache.streampipes.user.management.util.TokenUtil;
+
+import java.util.UUID;
+
+public class RefreshTokenService {
+
+ private static final int REFRESH_TOKEN_LENGTH = 64;
+ private static final long SESSION_REFRESH_TOKEN_TTL_MILLIS = 24L * 60 * 60 *
1000;
+ private static final long REMEMBER_ME_REFRESH_TOKEN_TTL_MILLIS = 30L * 24 *
60 * 60 * 1000;
+
+ private final IRefreshTokenStorage refreshTokenStorage;
+
+ public RefreshTokenService() {
+ this.refreshTokenStorage = StorageDispatcher.INSTANCE
+ .getNoSqlStore()
+ .getRefreshTokenStorage();
+ }
+
+ public IssuedRefreshToken issueRefreshToken(String principalId,
+ boolean rememberMe) {
+ long createdAtMillis = System.currentTimeMillis();
+ long expiresAtMillis = createdAtMillis + getTokenLifetime(rememberMe);
+
+ String rawToken = TokenUtil.generateToken(REFRESH_TOKEN_LENGTH);
+ String hashedToken = TokenUtil.hashToken(rawToken);
+ String tokenId = UUID.randomUUID().toString();
+ RefreshToken refreshToken = RefreshToken.create(
+ tokenId,
+ principalId,
+ hashedToken,
+ createdAtMillis,
+ expiresAtMillis,
+ rememberMe
+ );
+
+ persistOrThrow(refreshToken);
+
+ return new IssuedRefreshToken(tokenId, principalId, rawToken,
expiresAtMillis, rememberMe);
+ }
+
+ public IssuedRefreshToken rotateRefreshToken(String rawToken) {
+ String hashedToken = TokenUtil.hashToken(rawToken);
+ RefreshToken existingToken =
refreshTokenStorage.findByHashedToken(hashedToken);
+
+ if (!isValid(existingToken)) {
+ return null;
+ }
+
+ if (existingToken.getPrincipalId() == null) {
+ return null;
+ }
+
+ long now = System.currentTimeMillis();
+ IssuedRefreshToken replacement =
issueRefreshToken(existingToken.getPrincipalId(), existingToken.isRememberMe());
+
+ existingToken.setRevokedAtMillis(now);
+ existingToken.setReplacedByTokenId(replacement.tokenId());
+ refreshTokenStorage.updateElement(existingToken);
+
+ return replacement;
+ }
+
+ public void deleteAllRefreshTokens(String principalId) {
+ refreshTokenStorage
+ .findByPrincipalId(principalId)
+ .forEach(refreshTokenStorage::deleteElement);
+ }
+
+ public void deleteAllRefreshTokensByRawToken(String rawToken) {
+ String hashedToken = TokenUtil.hashToken(rawToken);
+ RefreshToken existingToken =
refreshTokenStorage.findByHashedToken(hashedToken);
+
+ if (existingToken != null && existingToken.getPrincipalId() != null) {
+ deleteAllRefreshTokens(existingToken.getPrincipalId());
+ }
+ }
+
+ private long getTokenLifetime(boolean rememberMe) {
+ return rememberMe ? REMEMBER_ME_REFRESH_TOKEN_TTL_MILLIS :
SESSION_REFRESH_TOKEN_TTL_MILLIS;
+ }
+
+ private boolean isValid(RefreshToken token) {
+ if (token == null) {
+ return false;
+ }
+
+ if (token.getRevokedAtMillis() != null) {
+ return false;
+ }
+
+ return token.getExpiresAtMillis() > System.currentTimeMillis();
+ }
+
+ private void persistOrThrow(RefreshToken refreshToken) {
+ var persistResult = refreshTokenStorage.persist(refreshToken);
+
+ if (!persistResult.k) {
+ throw new IllegalStateException(
+ String.format("Could not persist refresh token for principal '%s'",
refreshToken.getPrincipalId())
+ );
+ }
+ }
+
+ public record IssuedRefreshToken(String tokenId,
+ String principalId,
+ String rawToken,
+ long expiresAtMillis,
+ boolean rememberMe) {
+ }
+}
diff --git a/ui/src/app/_guards/auth.can-activate-children.guard.ts
b/ui/src/app/_guards/auth.can-activate-children.guard.ts
index 9ac4788e09..16bad5377e 100644
--- a/ui/src/app/_guards/auth.can-activate-children.guard.ts
+++ b/ui/src/app/_guards/auth.can-activate-children.guard.ts
@@ -20,30 +20,20 @@ import { Injectable } from '@angular/core';
import {
ActivatedRouteSnapshot,
CanActivateChild,
- Router,
+ GuardResult,
+ MaybeAsync,
RouterStateSnapshot,
} from '@angular/router';
import { AuthService } from '../services/auth.service';
@Injectable({ providedIn: 'root' })
export class AuthCanActivateChildrenGuard implements CanActivateChild {
- constructor(
- private authService: AuthService,
- private router: Router,
- ) {}
+ constructor(private authService: AuthService) {}
canActivateChild(
childRoute: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
- ): boolean {
- if (this.authService.authenticated()) {
- return true;
- }
- this.authService.logout();
- this.router.navigate(['/login'], {
- queryParams: { returnUrl: state.url },
- });
-
- return false;
+ ): MaybeAsync<GuardResult> {
+ return this.authService.ensureAuthenticated(state.url);
}
}
diff --git a/ui/src/app/_guards/auth.can-activate.guard.ts
b/ui/src/app/_guards/auth.can-activate.guard.ts
index 9af91a2acd..bca529dbdc 100644
--- a/ui/src/app/_guards/auth.can-activate.guard.ts
+++ b/ui/src/app/_guards/auth.can-activate.guard.ts
@@ -23,27 +23,17 @@ import {
CanActivate,
GuardResult,
MaybeAsync,
- Router,
RouterStateSnapshot,
} from '@angular/router';
@Injectable({ providedIn: 'root' })
export class AuthCanActivateGuard implements CanActivate {
private authService = inject(AuthService);
- private router = inject(Router);
canActivate(
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot,
): MaybeAsync<GuardResult> {
- if (this.authService.authenticated()) {
- return true;
- }
- this.authService.logout();
- this.router.navigate(['/login'], {
- queryParams: { returnUrl: state.url },
- });
-
- return false;
+ return this.authService.ensureAuthenticated(state.url);
}
}
diff --git a/ui/src/app/login/components/login/login.component.html
b/ui/src/app/login/components/login/login.component.html
index 65f1ae1248..eb1c81c70c 100644
--- a/ui/src/app/login/components/login/login.component.html
+++ b/ui/src/app/login/components/login/login.component.html
@@ -48,6 +48,9 @@
/>
</mat-form-field>
</sp-form-field>
+ <mat-checkbox formControlName="rememberMe" class="mt-10">
+ {{ 'Remember me' | translate }}
+ </mat-checkbox>
<div class="form-actions mt-20">
<button
mat-flat-button
diff --git a/ui/src/app/login/components/login/login.component.ts
b/ui/src/app/login/components/login/login.component.ts
index c59e3c73b6..6d7a665b9a 100644
--- a/ui/src/app/login/components/login/login.component.ts
+++ b/ui/src/app/login/components/login/login.component.ts
@@ -41,6 +41,7 @@ import {
import { MatFormField } from '@angular/material/form-field';
import { MatInput } from '@angular/material/input';
import { MatButton } from '@angular/material/button';
+import { MatCheckbox } from '@angular/material/checkbox';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
@@ -59,6 +60,7 @@ import { TranslatePipe } from '@ngx-translate/core';
MatFormField,
MatInput,
MatButton,
+ MatCheckbox,
MatProgressSpinner,
SpAlertBannerComponent,
RouterLink,
@@ -116,15 +118,19 @@ export class LoginComponent extends
BaseLoginPageDirective {
'password',
new UntypedFormControl('', Validators.required),
);
+ this.parentForm.addControl('rememberMe', new
UntypedFormControl(false));
this.parentForm.valueChanges.subscribe(v => {
this.credentials.username = v.username;
this.credentials.password = v.password;
+ this.credentials.rememberMe = v.rememberMe;
});
+ this.credentials.rememberMe = false;
this.returnUrl = this.route.snapshot.queryParams.returnUrl || '';
}
doOAuthLogin(provider: string): void {
- window.location.href =
`/streampipes-backend/oauth2/authorization/${provider}?redirect_uri=${this.loginSettings.oAuthSettings.redirectUri}/%23/login`;
+ const rememberMe = !!this.parentForm?.get('rememberMe')?.value;
+ window.location.href =
`/streampipes-backend/oauth2/authorization/${provider}?redirect_uri=${this.loginSettings.oAuthSettings.redirectUri}/%23/login&remember_me=${rememberMe}`;
}
}
diff --git a/ui/src/app/login/services/login.service.ts
b/ui/src/app/login/services/login.service.ts
index f0ab3144ea..fab0c0c03a 100644
--- a/ui/src/app/login/services/login.service.ts
+++ b/ui/src/app/login/services/login.service.ts
@@ -51,11 +51,24 @@ export class LoginService {
);
}
- renewToken(): Observable<any> {
- return this.http.get(
- this.platformServicesCommons.apiBasePath + '/auth/token/renew',
+ refreshToken(): Observable<any> {
+ return this.http.post(
+ this.platformServicesCommons.apiBasePath + '/auth/token/refresh',
+ {},
+ {
+ context: new HttpContext().set(NGX_LOADING_BAR_IGNORED, true),
+ withCredentials: true,
+ },
+ );
+ }
+
+ logout(): Observable<any> {
+ return this.http.post(
+ this.platformServicesCommons.apiBasePath + '/auth/logout',
+ {},
{
context: new HttpContext().set(NGX_LOADING_BAR_IGNORED, true),
+ withCredentials: true,
},
);
}
diff --git a/ui/src/app/services/auth.service.ts
b/ui/src/app/services/auth.service.ts
index a3da9a4fdb..eab8325681 100644
--- a/ui/src/app/services/auth.service.ts
+++ b/ui/src/app/services/auth.service.ts
@@ -18,9 +18,17 @@
import { RestApi } from './rest-api.service';
import { Injectable } from '@angular/core';
-import { Observable, timer } from 'rxjs';
+import { Observable, of, timer } from 'rxjs';
import { JwtHelperService } from '@auth0/angular-jwt';
-import { filter, map, switchMap } from 'rxjs/operators';
+import {
+ catchError,
+ filter,
+ finalize,
+ map,
+ shareReplay,
+ switchMap,
+ tap,
+} from 'rxjs/operators';
import { Router } from '@angular/router';
import { LoginService } from '../login/services/login.service';
import {
@@ -30,6 +38,8 @@ import {
@Injectable({ providedIn: 'root' })
export class AuthService {
+ private refreshInFlight$?: Observable<boolean>;
+
constructor(
private restApi: RestApi,
private tokenStorage: JwtTokenStorageService,
@@ -37,12 +47,12 @@ export class AuthService {
private router: Router,
private loginService: LoginService,
) {
- if (this.authenticated()) {
+ if (this.authenticated() && tokenStorage.getUser()) {
this.currentUserService.authToken$.next(tokenStorage.getToken());
this.currentUserService.user$.next(tokenStorage.getUser());
this.currentUserService.isLoggedIn$.next(true);
} else {
- this.logout();
+ this.clearLocalAuthState();
}
this.scheduleTokenRenew();
this.watchTokenExpiration();
@@ -55,6 +65,7 @@ export class AuthService {
this.tokenStorage.saveUser(decodedToken.user);
this.currentUserService.authToken$.next(data.accessToken);
this.currentUserService.user$.next(decodedToken.user);
+ this.currentUserService.isLoggedIn$.next(true);
}
public oauthLogin(token: string) {
@@ -64,11 +75,30 @@ export class AuthService {
this.tokenStorage.saveUser(decodedToken.user);
this.currentUserService.authToken$.next(token);
this.currentUserService.user$.next(decodedToken.user);
+ this.currentUserService.isLoggedIn$.next(true);
}
public logout() {
- this.tokenStorage.clearTokens();
- this.currentUserService.authToken$.next(undefined);
+ this.loginService.logout().subscribe({
+ next: () => this.clearLocalAuthState(),
+ error: () => this.clearLocalAuthState(),
+ });
+ }
+
+ public ensureAuthenticated(returnUrl: string): Observable<boolean> {
+ if (this.authenticated()) {
+ return of(true);
+ }
+
+ return this.refreshAccessToken().pipe(
+ tap(authenticated => {
+ if (!authenticated) {
+ this.router.navigate(['/login'], {
+ queryParams: { returnUrl },
+ });
+ }
+ }),
+ );
}
public authenticated(): boolean {
@@ -84,11 +114,6 @@ export class AuthService {
);
}
- public decodeJwtToken(token: string): any {
- const jwtHelper: JwtHelperService = new JwtHelperService({});
- return jwtHelper.decodeToken(token);
- }
-
checkConfiguration(): Observable<boolean> {
return Observable.create(observer =>
this.restApi.configured().subscribe(
@@ -113,21 +138,22 @@ export class AuthService {
map((token: any) =>
new JwtHelperService({}).getTokenExpirationDate(token),
),
+ filter(
+ (expiresIn: Date | null): expiresIn is Date => !!expiresIn,
+ ),
switchMap((expiresIn: Date) =>
timer(expiresIn.getTime() - Date.now() - 60000),
),
)
.subscribe(() => {
- if (this.authenticated()) {
+ if (this.currentUserService.authToken$.getValue()) {
this.updateTokenAndUserInfo();
}
});
}
updateTokenAndUserInfo() {
- this.loginService.renewToken().subscribe(data => {
- this.login(data);
- });
+ this.refreshAccessToken().subscribe();
}
watchTokenExpiration() {
@@ -137,16 +163,50 @@ export class AuthService {
map((token: any) =>
new JwtHelperService({}).getTokenExpirationDate(token),
),
+ filter(
+ (expiresIn: Date | null): expiresIn is Date => !!expiresIn,
+ ),
switchMap((expiresIn: Date) =>
timer(expiresIn.getTime() - Date.now() + 1),
),
)
.subscribe(() => {
- this.logout();
- this.router.navigate(['login']);
+ this.refreshAccessToken().subscribe(authenticated => {
+ if (!authenticated) {
+ this.router.navigate(['login']);
+ }
+ });
});
}
+ private refreshAccessToken(): Observable<boolean> {
+ if (this.refreshInFlight$) {
+ return this.refreshInFlight$;
+ }
+
+ this.refreshInFlight$ = this.loginService.refreshToken().pipe(
+ tap(data => this.login(data)),
+ map(() => true),
+ catchError(() => {
+ this.clearLocalAuthState();
+ return of(false);
+ }),
+ finalize(() => {
+ this.refreshInFlight$ = undefined;
+ }),
+ shareReplay({ bufferSize: 1, refCount: true }),
+ );
+
+ return this.refreshInFlight$;
+ }
+
+ private clearLocalAuthState() {
+ this.tokenStorage.clearTokens();
+ this.currentUserService.authToken$.next(undefined);
+ this.currentUserService.user$.next(undefined);
+ this.currentUserService.isLoggedIn$.next(false);
+ }
+
public hasRole(role: string): boolean {
return this.currentUserService.hasRole(role);
}