This is an automated email from the ASF dual-hosted git repository. riemer pushed a commit to branch support-refresh-tokens in repository https://gitbox.apache.org/repos/asf/streampipes.git
commit e329fedcbf2625d13acfa71b34354ca79c1fac78 Author: Dominik Riemer <[email protected]> AuthorDate: Fri Mar 6 08:31:11 2026 +0100 feat: Support refresh tokens --- .../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); }
