This is an automated email from the ASF dual-hosted git repository.
gongchao pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hertzbeat.git
The following commit(s) were added to refs/heads/master by this push:
new 3e6a7962f6 [feat] add API token management functionality (#4080)
3e6a7962f6 is described below
commit 3e6a7962f6f538bb0b9ee8b58e8929a465c1778d
Author: Duansg <[email protected]>
AuthorDate: Sat Mar 21 16:20:59 2026 +0800
[feat] add API token management functionality (#4080)
Co-authored-by: Tomsun28 <[email protected]>
---
e2e/testsuite.yaml | 60 ++++
.../hertzbeat/common/entity/manager/AuthToken.java | 94 ++++++
.../config/ApiTokenValidationConfiguration.java | 40 +++
.../manager/config/ApiTokenValidationFilter.java | 168 ++++++++++
.../manager/controller/AuthTokenController.java | 62 +++-
.../apache/hertzbeat/manager/dao/AuthTokenDao.java | 58 ++++
.../hertzbeat/manager/service/AccountService.java | 53 +++-
.../manager/service/impl/AccountServiceImpl.java | 191 +++++++++++-
.../config/ApiTokenValidationFilterTest.java | 263 ++++++++++++++++
.../controller/AuthTokenControllerTest.java | 226 ++++++++++++++
.../manager/service/AccountServiceTest.java | 343 ++++++++++++++++++++-
hertzbeat-startup/src/main/resources/sureness.yml | 3 +
.../alert-integration.component.html | 39 +--
.../alert-integration.component.ts | 41 +--
.../log-integration/log-integration.component.html | 39 +--
.../log-integration/log-integration.component.ts | 41 +--
.../app/routes/setting/setting-routing.module.ts | 6 +
web-app/src/app/routes/setting/setting.module.ts | 6 +-
.../routes/setting/settings/settings.component.ts | 5 +
.../token-management.component.html | 143 +++++++++
.../token-management.component.less | 26 ++
.../token-management/token-management.component.ts | 185 +++++++++++
web-app/src/app/service/auth.service.ts | 19 +-
web-app/src/assets/i18n/en-US.json | 26 ++
web-app/src/assets/i18n/zh-CN.json | 26 ++
25 files changed, 1979 insertions(+), 184 deletions(-)
diff --git a/e2e/testsuite.yaml b/e2e/testsuite.yaml
index 25a37f8433..9ada700442 100644
--- a/e2e/testsuite.yaml
+++ b/e2e/testsuite.yaml
@@ -29,6 +29,7 @@ param:
monitorUDP: "{{randAlpha 6}}"
monitorWebsite: "{{randAlpha 6}}"
monitorFTP: "{{randAlpha 6}}"
+ apiTokenName: "e2e-token-{{randAlpha 6}}"
labelName: "{{randAlpha 3}}"
items:
- name: login
@@ -69,8 +70,13 @@ items:
- name: missing-auth-header
request:
api: /api/monitors
+ header:
+ Accept: application/json
expect:
statusCode: 401
+ bodyFieldsExpect:
+ code: 401
+ msg: Missing Authorization in header
- name: createSitemapMonitor
request:
api: /api/monitor
@@ -385,6 +391,60 @@ items:
header:
Authorization: Bearer {{.login.data.token}}
+## API Token Management
+- name: generateApiToken
+ request:
+ api: /api/account/token/generate?name={{.apiTokenName}}&expireSeconds=-1
+ method: POST
+ header:
+ Authorization: Bearer {{.login.data.token}}
+ expect:
+ bodyFieldsExpect:
+ code: "0"
+- name: listApiTokens
+ request:
+ api: /api/account/token
+ header:
+ Authorization: Bearer {{.login.data.token}}
+ expect:
+ bodyFieldsExpect:
+ code: "0"
+ data.0.name: "{{.apiTokenName}}"
+ data.0.creator: "admin"
+- name: listApiTokensByGeneratedToken
+ request:
+ api: /api/account/token
+ header:
+ Authorization: Bearer {{.generateApiToken.data.token}}
+ expect:
+ bodyFieldsExpect:
+ code: "0"
+ data.0.name: "{{.apiTokenName}}"
+- name: useGeneratedTokenToCallApi
+ request:
+ api: /api/monitors?pageIndex=0&pageSize=8
+ header:
+ Authorization: Bearer {{.generateApiToken.data.token}}
+ expect:
+ bodyFieldsExpect:
+ code: "0"
+- name: deleteApiToken
+ request:
+ api: /api/account/token/{{(index .listApiTokens.data 0).id | int64}}
+ method: DELETE
+ header:
+ Authorization: Bearer {{.login.data.token}}
+ expect:
+ bodyFieldsExpect:
+ code: "0"
+- name: useRevokedTokenToCallApi
+ request:
+ api: /api/monitors?pageIndex=0&pageSize=8
+ header:
+ Authorization: Bearer {{.generateApiToken.data.token}}
+ expect:
+ statusCode: 401
+
## Config
- name: getConfigEmail
request:
diff --git
a/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java
new file mode 100644
index 0000000000..1845c36e91
--- /dev/null
+++
b/hertzbeat-common-spring/src/main/java/org/apache/hertzbeat/common/entity/manager/AuthToken.java
@@ -0,0 +1,94 @@
+/*
+ * 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.hertzbeat.common.entity.manager;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.EntityListeners;
+import jakarta.persistence.GeneratedValue;
+import jakarta.persistence.GenerationType;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.springframework.data.annotation.CreatedBy;
+import org.springframework.data.annotation.CreatedDate;
+import org.springframework.data.jpa.domain.support.AuditingEntityListener;
+
+import java.time.LocalDateTime;
+
+import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_ONLY;
+import static io.swagger.v3.oas.annotations.media.Schema.AccessMode.READ_WRITE;
+
+/**
+ * API Token Entity - stores generated non-expiring API tokens for management
and revocation.
+ * <p>
+ * Only "managed" tokens (those generated after this feature was introduced)
are stored here.
+ * Legacy tokens are not tracked and continue to work without DB validation.
+ * </p>
+ */
+@Entity
+@Table(name = "hzb_auth_token")
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+@Schema(description = "API Token entity")
+@EntityListeners(AuditingEntityListener.class)
+public class AuthToken {
+
+ @Id
+ @GeneratedValue(strategy = GenerationType.IDENTITY)
+ @Schema(title = "primary id", example = "1", accessMode = READ_ONLY)
+ private Long id;
+
+ @Schema(title = "Token name/description", example = "Alert integration
token", accessMode = READ_WRITE)
+ @Column(length = 255)
+ private String name;
+
+ @Schema(title = "SHA-256 hash of the token value", accessMode = READ_ONLY)
+ @Column(length = 128, nullable = false, unique = true)
+ private String tokenHash;
+
+ @Schema(title = "Masked token for display", example = "eyJh****xMjM",
accessMode = READ_ONLY)
+ @Column(length = 64)
+ private String tokenMask;
+
+ @Schema(title = "Token status: 0-active", accessMode = READ_ONLY)
+ @Column(nullable = false)
+ @Builder.Default
+ private Byte status = 0;
+
+ @Schema(title = "The creator of this token", accessMode = READ_ONLY)
+ @CreatedBy
+ private String creator;
+
+ @Schema(title = "Token creation time", accessMode = READ_ONLY)
+ @CreatedDate
+ private LocalDateTime gmtCreate;
+
+ @Schema(title = "Token expiration time, null means never expire",
accessMode = READ_ONLY)
+ private LocalDateTime expireTime;
+
+ @Schema(title = "Token last used time", accessMode = READ_ONLY)
+ private LocalDateTime lastUsedTime;
+
+}
diff --git
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationConfiguration.java
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationConfiguration.java
new file mode 100644
index 0000000000..ebd114de65
--- /dev/null
+++
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationConfiguration.java
@@ -0,0 +1,40 @@
+/*
+ * 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.hertzbeat.manager.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Registers the managed API token validation interceptor after servlet
filters.
+ */
+@Configuration
+public class ApiTokenValidationConfiguration implements WebMvcConfigurer {
+
+ private final ApiTokenValidationFilter apiTokenValidationFilter;
+
+ public ApiTokenValidationConfiguration(ApiTokenValidationFilter
apiTokenValidationFilter) {
+ this.apiTokenValidationFilter = apiTokenValidationFilter;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+
registry.addInterceptor(apiTokenValidationFilter).addPathPatterns("/api/**");
+ }
+}
diff --git
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java
new file mode 100644
index 0000000000..4075d2e1d6
--- /dev/null
+++
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilter.java
@@ -0,0 +1,168 @@
+/*
+ * 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.hertzbeat.manager.config;
+
+import com.usthe.sureness.subject.PrincipalMap;
+import com.usthe.sureness.subject.SubjectSum;
+import com.usthe.sureness.util.SurenessContextHolder;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import java.io.IOException;
+import java.io.PrintWriter;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.hertzbeat.collector.dispatch.DispatchConstants;
+import org.apache.hertzbeat.common.constants.NetworkConstants;
+import org.apache.hertzbeat.common.util.JsonUtil;
+import org.apache.hertzbeat.manager.service.AccountService;
+import org.apache.hertzbeat.manager.service.impl.AccountServiceImpl;
+import org.jspecify.annotations.NonNull;
+import org.springframework.http.HttpStatus;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.HandlerInterceptor;
+
+/**
+ * Interceptor to validate that managed API tokens have not been revoked
(deleted).
+ * <p>
+ * This interceptor runs AFTER the Sureness authentication filter to ensure:
+ * 1. Only authenticated requests are checked (avoids unnecessary DB hits)
+ * 2. lastUsedTime is only updated for successfully authenticated requests
+ * <p>
+ * Responsibility split:
+ * - <b>Expiration</b>: handled by Sureness/JWT natively via the {@code exp}
claim
+ * - <b>Revocation</b>: handled here by checking the token's status in the
database
+ * - <b>Last used time</b>: updated here on each valid request (throttled)
+ * <p>
+ * Only tokens containing the "managed" claim (generated by the new token
management system)
+ * are validated against the database. Legacy tokens without this claim are
allowed to pass
+ * through for backward compatibility.
+ * </p>
+ */
+@Component
+@Slf4j
+public class ApiTokenValidationFilter implements HandlerInterceptor {
+
+ private static final String ROLES_CLAIM = "roles";
+ private static final String TOKEN_VALIDATION_UNAVAILABLE = "Token
validation unavailable";
+
+ private final AccountService accountService;
+
+ public ApiTokenValidationFilter(AccountService accountService) {
+ this.accountService = accountService;
+ }
+
+ @Override
+ public boolean preHandle(@NonNull HttpServletRequest request, @NonNull
HttpServletResponse response, @NonNull Object handler)
+ throws IOException {
+ SubjectSum subject = SurenessContextHolder.getBindSubject();
+ if (subject == null || !isManagedToken(subject)) {
+ return true;
+ }
+ String authorization =
request.getHeader(NetworkConstants.AUTHORIZATION);
+
+ if (authorization != null &&
authorization.startsWith(DispatchConstants.BEARER)) {
+ String token =
authorization.substring(DispatchConstants.BEARER.length()).trim();
+ if (!token.isEmpty()) {
+ try {
+ String rejectReason = checkManagedToken(subject, token);
+ if (rejectReason != null) {
+ return writeError(response, HttpStatus.UNAUTHORIZED,
rejectReason);
+ }
+ } catch (RuntimeException e) {
+ log.warn("Managed token validation failed", e);
+ return writeError(response,
HttpStatus.SERVICE_UNAVAILABLE, TOKEN_VALIDATION_UNAVAILABLE);
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Check if a managed token should be rejected.
+ * <p>
+ * Returns a rejection reason string if the token should be rejected, null
if allowed.
+ * Checks:
+ * 1. Only managed tokens (with "managed" claim) are validated — legacy
tokens pass through
+ * 2. Token must exist and be active in the database (not deleted/revoked)
+ * 3. Token owner must still exist, remain active, and still own the
claimed roles
+ * If the token is valid, updates the last used time.
+ * </p>
+ */
+ private String checkManagedToken(SubjectSum subject, String token) {
+ String rejectReason = accountService.checkTokenStatus(token);
+ if (rejectReason != null) {
+ return rejectReason;
+ }
+ rejectReason =
accountService.checkManagedTokenAccess(getCurrentUserId(subject),
extractClaimedRoles(subject));
+ if (rejectReason != null) {
+ return rejectReason;
+ }
+ try {
+ accountService.touchTokenLastUsedTime(token);
+ } catch (RuntimeException e) {
+ log.debug("Failed to update token last used time", e);
+ }
+ return null;
+ }
+
+ private boolean isManagedToken(SubjectSum subject) {
+ PrincipalMap principalMap = subject.getPrincipalMap();
+ if (principalMap == null) {
+ return false;
+ }
+ Object managed =
principalMap.getPrincipal(AccountServiceImpl.CLAIM_MANAGED);
+ return managed instanceof Boolean ? (Boolean) managed :
Boolean.parseBoolean(String.valueOf(managed));
+ }
+
+ @SuppressWarnings("unchecked")
+ private List<String> extractClaimedRoles(SubjectSum subject) {
+ Object roles = subject.getRoles();
+ if (roles instanceof List<?>) {
+ return (List<String>) roles;
+ }
+ PrincipalMap principalMap = subject.getPrincipalMap();
+ if (principalMap == null) {
+ return Collections.emptyList();
+ }
+ Object claimedRoles = principalMap.getPrincipal(ROLES_CLAIM);
+ if (claimedRoles instanceof List<?>) {
+ return (List<String>) claimedRoles;
+ }
+ return Collections.emptyList();
+ }
+
+ private String getCurrentUserId(SubjectSum subject) {
+ Object principal = subject.getPrincipal();
+ return principal == null ? null : String.valueOf(principal);
+ }
+
+ private boolean writeError(HttpServletResponse response, HttpStatus
status, String message) throws IOException {
+ response.setStatus(status.value());
+ response.setCharacterEncoding("UTF-8");
+ response.setContentType("application/json");
+ try (PrintWriter writer = response.getWriter()) {
+ writer.write(JsonUtil.toJson(Map.of("code", status.value(), "msg",
message)));
+ }
+ return false;
+ }
+}
diff --git
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
index 0432ad6955..4aa3abb954 100644
---
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
+++
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/controller/AuthTokenController.java
@@ -22,22 +22,29 @@ import static
org.springframework.http.MediaType.APPLICATION_JSON_VALUE;
import com.usthe.sureness.subject.SubjectSum;
import com.usthe.sureness.util.SurenessContextHolder;
import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Collections;
+import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.hertzbeat.common.entity.dto.Message;
+import org.apache.hertzbeat.common.entity.manager.AuthToken;
import org.apache.hertzbeat.manager.service.AccountService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.DeleteMapping;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
- * Generate TOKEN API
+ * API Token Management Controller
*/
-@Tag(name = "Generate TOKEN API")
+@Tag(name = "API Token Management")
@RestController
@RequestMapping(value = "/api/account/token", produces =
{APPLICATION_JSON_VALUE})
@Slf4j
@@ -47,8 +54,10 @@ public class AuthTokenController {
private AccountService accountService;
@PostMapping("/generate")
- @Operation(summary = "Use refresh TOKEN to re-acquire TOKEN", description
= "Use refresh TOKEN to re-acquire TOKEN")
- public ResponseEntity<Message<Map<String, String>>> generateToken() {
+ @Operation(summary = "Generate a new API token", description = "Generate a
new API token for integrations, optionally with expiration")
+ public ResponseEntity<Message<Map<String, String>>> generateToken(
+ @RequestParam(value = "name", required = false)
@Parameter(description = "Token name/description") String name,
+ @RequestParam(value = "expireSeconds", required = false)
@Parameter(description = "Expiration time in seconds, null means never expire")
Long expireSeconds) {
SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
if (subjectSum == null) {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "No login user"));
@@ -57,12 +66,53 @@ public class AuthTokenController {
return ResponseEntity.ok(Message.fail(FAIL_CODE, "No permission"));
}
try {
- String token = accountService.generateToken();
+ String token = accountService.generateToken(name, expireSeconds);
Map<String, String> rep = Collections.singletonMap("token", token);
- return ResponseEntity.ok(Message.success(rep));
+ return ResponseEntity.ok(Message.success(rep));
} catch (Exception e) {
log.error("generate token error", e);
return ResponseEntity.ok(Message.fail(FAIL_CODE, "Generate token
error"));
}
}
+
+ @GetMapping
+ @Operation(summary = "List all API tokens", description = "List all active
non-expiring API tokens")
+ public ResponseEntity<Message<List<AuthToken>>> listTokens() {
+ SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
+ if (subjectSum == null) {
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "No login user"));
+ }
+ if (!subjectSum.hasRole("admin")) {
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "No permission"));
+ }
+ try {
+ List<AuthToken> tokens = accountService.listTokens();
+ return ResponseEntity.ok(Message.success(tokens));
+ } catch (Exception e) {
+ log.error("list tokens error", e);
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "List tokens
error"));
+ }
+ }
+
+ @DeleteMapping("/{id}")
+ @Operation(summary = "Delete/revoke an API token", description = "Delete
an API token to revoke its access")
+ public ResponseEntity<Message<Void>> deleteToken(
+ @PathVariable("id") @Parameter(description = "Token ID") Long id) {
+ SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
+ if (subjectSum == null) {
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "No login user"));
+ }
+ if (!subjectSum.hasRole("admin")) {
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "No permission"));
+ }
+ try {
+ accountService.deleteToken(id);
+ return ResponseEntity.ok(Message.success(null));
+ } catch (javax.naming.AuthenticationException e) {
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "No permission"));
+ } catch (Exception e) {
+ log.error("delete token error", e);
+ return ResponseEntity.ok(Message.fail(FAIL_CODE, "Delete token
error"));
+ }
+ }
}
diff --git
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java
new file mode 100644
index 0000000000..d185cdaecd
--- /dev/null
+++
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/dao/AuthTokenDao.java
@@ -0,0 +1,58 @@
+/*
+ * 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.hertzbeat.manager.dao;
+
+import org.apache.hertzbeat.common.entity.manager.AuthToken;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * AuthToken DAO
+ */
+public interface AuthTokenDao extends JpaRepository<AuthToken, Long>,
JpaSpecificationExecutor<AuthToken> {
+
+ /**
+ * Find all active tokens (status = 0)
+ */
+ List<AuthToken> findByStatus(Byte status);
+
+ /**
+ * Find active tokens created by the given user
+ */
+ List<AuthToken> findByStatusAndCreator(Byte status, String creator);
+
+ /**
+ * Check if an active token exists with the given hash
+ */
+ boolean existsByTokenHashAndStatus(String tokenHash, Byte status);
+
+ /**
+ * Update last used time for a token identified by its hash
+ */
+ @Modifying
+ @Transactional
+ @Query("UPDATE AuthToken t SET t.lastUsedTime = :lastUsedTime WHERE
t.tokenHash = :tokenHash")
+ void updateLastUsedTime(@Param("tokenHash") String tokenHash,
@Param("lastUsedTime") LocalDateTime lastUsedTime);
+}
diff --git
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
index 6820ad043b..58fe2c1c7e 100644
---
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
+++
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/AccountService.java
@@ -17,8 +17,11 @@
package org.apache.hertzbeat.manager.service;
+import java.util.List;
import java.util.Map;
import javax.naming.AuthenticationException;
+
+import org.apache.hertzbeat.common.entity.manager.AuthToken;
import org.apache.hertzbeat.manager.pojo.dto.LoginDto;
import org.apache.hertzbeat.manager.pojo.dto.RefreshTokenResponse;
@@ -28,6 +31,7 @@ import
org.apache.hertzbeat.manager.pojo.dto.RefreshTokenResponse;
public interface AccountService {
/**
* Account password login to obtain associated user information
+ *
* @param loginDto loginDto
* @return token info
* @throws AuthenticationException when authentication is failed
@@ -36,6 +40,7 @@ public interface AccountService {
/**
* Use refresh TOKEN to re-acquire TOKEN
+ *
* @param refreshToken refreshToken
* @return token and refresh token
* @throws Exception failed to refresh
@@ -43,8 +48,50 @@ public interface AccountService {
RefreshTokenResponse refreshToken(String refreshToken) throws Exception;
/**
- * Generate no expired token
- * @return token
+ * Generate a managed API token and persist it for lifecycle management
+ *
+ * @param tokenName optional name/description for the token
+ * @param expireSeconds optional expiration time in seconds, null means
never expire
+ * @return token string
+ */
+ String generateToken(String tokenName, Long expireSeconds) throws
AuthenticationException;
+
+ /**
+ * List all API tokens
+ *
+ * @return list of auth tokens
+ */
+ List<AuthToken> listTokens();
+
+ /**
+ * Delete/revoke an API token by id
+ *
+ * @param id token id
+ */
+ void deleteToken(Long id) throws AuthenticationException;
+
+ /**
+ * Check the status of a managed token (revocation + expiration)
+ *
+ * @param tokenValue the raw token string
+ * @return null if the token is valid, otherwise a rejection reason string
+ */
+ String checkTokenStatus(String tokenValue);
+
+ /**
+ * Check whether the current account behind a managed token is still valid
+ * and still owns the roles claimed in that token.
+ *
+ * @param userId token subject
+ * @param claimedRoles roles embedded in the token claims
+ * @return null if the token owner is still valid, otherwise a rejection
reason string
+ */
+ String checkManagedTokenAccess(String userId, List<String> claimedRoles);
+
+ /**
+ * Update the last used time of a managed token
+ *
+ * @param tokenValue the raw token string
*/
- String generateToken() throws AuthenticationException;
+ void touchTokenLastUsedTime(String tokenValue);
}
diff --git
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
index 3b74469764..1cadceb28d 100644
---
a/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
+++
b/hertzbeat-manager/src/main/java/org/apache/hertzbeat/manager/service/impl/AccountServiceImpl.java
@@ -17,6 +17,8 @@
package org.apache.hertzbeat.manager.service.impl;
+import com.github.benmanes.caffeine.cache.Cache;
+import com.github.benmanes.caffeine.cache.Caffeine;
import com.usthe.sureness.provider.SurenessAccount;
import com.usthe.sureness.provider.SurenessAccountProvider;
import com.usthe.sureness.provider.ducument.DocumentAccountProvider;
@@ -25,22 +27,29 @@ import com.usthe.sureness.util.JsonWebTokenUtil;
import com.usthe.sureness.util.Md5Util;
import com.usthe.sureness.util.SurenessContextHolder;
import io.jsonwebtoken.Claims;
-
-import java.util.HashMap;
-import java.util.List;
-import java.util.Map;
-import javax.naming.AuthenticationException;
-
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
+import org.apache.hertzbeat.alert.util.CryptoUtils;
+import org.apache.hertzbeat.common.entity.manager.AuthToken;
import org.apache.hertzbeat.common.util.JsonUtil;
+import org.apache.hertzbeat.manager.dao.AuthTokenDao;
import org.apache.hertzbeat.manager.pojo.dto.LoginDto;
import org.apache.hertzbeat.manager.pojo.dto.RefreshTokenResponse;
import org.apache.hertzbeat.manager.service.AccountService;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.stereotype.Service;
+import javax.naming.AuthenticationException;
+import java.time.LocalDateTime;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.TimeUnit;
+
/**
* Implementation of Account service
*/
@@ -56,6 +65,41 @@ public class AccountServiceImpl implements AccountService {
*/
private static final long PERIOD_TIME = 3600L;
+ /**
+ * Non-expiring managed token duration: 100 years in seconds
+ */
+ private static final long NON_EXPIRING_TOKEN_SECONDS = 365L * 24 * 3600 *
100;
+
+ private static final byte TOKEN_STATUS_ACTIVE = 0;
+
+ /**
+ * Custom JWT claim key to mark tokens as managed (persisted in DB for
lifecycle management).
+ * Only tokens with this claim will be validated against the database.
+ * Legacy tokens without this claim are allowed to pass through for
backward compatibility.
+ */
+ public static final String CLAIM_MANAGED = "managed";
+
+ /**
+ * Minimum interval (in minutes) between lastUsedTime DB updates for the
same token.
+ * Reduces write pressure under high-frequency requests.
+ */
+ private static final long LAST_USED_UPDATE_INTERVAL_MINUTES = 5;
+
+ /**
+ * Local cache: tokenHash -> isActive.
+ * TTL 60s to reduce DB queries; invalidated on token revocation.
+ */
+ private final Cache<String, Boolean> tokenStatusCache =
Caffeine.newBuilder()
+ .maximumSize(1000)
+ .expireAfterWrite(60, TimeUnit.SECONDS)
+ .build();
+
+ /**
+ * Tracks when each token's lastUsedTime was last written to DB.
+ * Used to throttle DB writes to at most once per
LAST_USED_UPDATE_INTERVAL_MINUTES.
+ */
+ private final ConcurrentHashMap<String, LocalDateTime>
lastUsedWriteTimestamps = new ConcurrentHashMap<>();
+
/**
* account data provider
*/
@@ -69,6 +113,9 @@ public class AccountServiceImpl implements AccountService {
this.accountProvider = accountProvider;
}
+ @Autowired
+ private AuthTokenDao authTokenDao;
+
@Override
public Map<String, String> authGetToken(LoginDto loginDto) throws
AuthenticationException {
SurenessAccount account =
accountProvider.loadAccount(loginDto.getIdentifier());
@@ -121,9 +168,9 @@ public class AccountServiceImpl implements AccountService {
}
@Override
- public String generateToken() throws AuthenticationException {
- SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
- String userId = String.valueOf(subjectSum.getPrincipal());
+ public String generateToken(String tokenName, Long expireSeconds) throws
AuthenticationException {
+ SubjectSum subjectSum = requireCurrentSubject();
+ String userId = getCurrentUserId(subjectSum);
SurenessAccount account = accountProvider.loadAccount(userId);
if (account == null) {
throw new AuthenticationException("Not Exists This Token Mapping
Account");
@@ -132,11 +179,114 @@ public class AccountServiceImpl implements
AccountService {
throw new AuthenticationException("Expired or Illegal Account");
}
List<String> roles = account.getOwnRoles();
- return issueApiToken(userId, roles);
+ String token = issueApiToken(userId, roles, expireSeconds);
+
+ // Persist token metadata for management
+ String tokenHash = CryptoUtils.sha256Hex(token);
+ String tokenMask = maskToken(token);
+ LocalDateTime expireTime = null;
+ if (expireSeconds != null && expireSeconds > 0) {
+ expireTime = LocalDateTime.now().plusSeconds(expireSeconds);
+ }
+ AuthToken authToken = AuthToken.builder()
+ .name(tokenName)
+ .tokenHash(tokenHash)
+ .tokenMask(tokenMask)
+ .status(TOKEN_STATUS_ACTIVE)
+ .creator(userId)
+ .expireTime(expireTime)
+ .build();
+ authTokenDao.save(authToken);
+
+ return token;
}
- private String issueAccessToken(String userId, List<String> roles, Long
expirationMillis) {
- return JsonWebTokenUtil.issueJwt(userId, expirationMillis, roles, new
HashMap<>(0));
+ private String issueAccessToken(String userId, List<String> roles, long
expirationSeconds) {
+ return JsonWebTokenUtil.issueJwt(userId, expirationSeconds, roles, new
HashMap<>(0));
+ }
+
+ @Override
+ public List<AuthToken> listTokens() {
+ SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
+ if (subjectSum == null) {
+ return List.of();
+ }
+ if (subjectSum.hasRole("admin")) {
+ return authTokenDao.findByStatus(TOKEN_STATUS_ACTIVE);
+ }
+ return authTokenDao.findByStatusAndCreator(TOKEN_STATUS_ACTIVE,
getCurrentUserId(subjectSum));
+ }
+
+ @Override
+ public void deleteToken(Long id) throws AuthenticationException {
+ SubjectSum subjectSum = requireCurrentSubject();
+ String userId = getCurrentUserId(subjectSum);
+ AuthToken token = authTokenDao.findById(id).orElse(null);
+ if (token == null) {
+ return;
+ }
+ if (!subjectSum.hasRole("admin") && !StringUtils.equals(userId,
token.getCreator())) {
+ throw new AuthenticationException("No permission");
+ }
+ tokenStatusCache.invalidate(token.getTokenHash());
+ lastUsedWriteTimestamps.remove(token.getTokenHash());
+ authTokenDao.deleteById(id);
+ }
+
+ @Override
+ public String checkTokenStatus(String tokenValue) {
+ String tokenHash = CryptoUtils.sha256Hex(tokenValue);
+ Boolean active = tokenStatusCache.get(tokenHash,
+ hash -> authTokenDao.existsByTokenHashAndStatus(hash,
TOKEN_STATUS_ACTIVE));
+ if (!Boolean.TRUE.equals(active)) {
+ return "Token has been revoked";
+ }
+ return null;
+ }
+
+ @Override
+ public String checkManagedTokenAccess(String userId, List<String>
claimedRoles) {
+ if (StringUtils.isBlank(userId)) {
+ return "Token owner account is no longer valid";
+ }
+ SurenessAccount account = accountProvider.loadAccount(userId);
+ if (account == null || account.isDisabledAccount() ||
account.isExcessiveAttempts()) {
+ return "Token owner account is no longer valid";
+ }
+ if (claimedRoles == null || claimedRoles.isEmpty()) {
+ return null;
+ }
+ List<String> currentRoles = account.getOwnRoles();
+ if (currentRoles == null || !new
HashSet<>(currentRoles).containsAll(claimedRoles)) {
+ return "Token permissions are outdated";
+ }
+ return null;
+ }
+
+ @Override
+ public void touchTokenLastUsedTime(String tokenValue) {
+ String tokenHash = CryptoUtils.sha256Hex(tokenValue);
+ LocalDateTime now = LocalDateTime.now();
+ LocalDateTime lastWrite = lastUsedWriteTimestamps.get(tokenHash);
+ if (lastWrite != null &&
lastWrite.plusMinutes(LAST_USED_UPDATE_INTERVAL_MINUTES).isAfter(now)) {
+ return;
+ }
+ lastUsedWriteTimestamps.put(tokenHash, now);
+ authTokenDao.updateLastUsedTime(tokenHash, now);
+ }
+
+ /**
+ * Issue a api token with the "managed" claim.
+ * This claim distinguishes new managed tokens from legacy unmanaged
tokens,
+ * allowing the validation filter to only check managed tokens against the
DB.
+ *
+ * @param expireSeconds optional expiration in seconds, null means never
expire
+ */
+ private String issueApiToken(String userId, List<String> roles, Long
expireSeconds) {
+ Map<String, Object> customClaimMap = new HashMap<>(2);
+ customClaimMap.put(CLAIM_MANAGED, true);
+ long effectiveExpire = (expireSeconds != null && expireSeconds > 0) ?
expireSeconds : NON_EXPIRING_TOKEN_SECONDS;
+ return JsonWebTokenUtil.issueJwt(userId, effectiveExpire, roles,
customClaimMap);
}
private String issueRefreshToken(String userId, Long expirationMillis) {
@@ -145,7 +295,20 @@ public class AccountServiceImpl implements AccountService {
return JsonWebTokenUtil.issueJwt(userId, expirationMillis,
customClaimMap);
}
- private String issueApiToken(String userId, List<String> roles) {
- return issueAccessToken(userId, roles, null);
+ private static String maskToken(String token) {
+ return token.substring(0, 4) + "****" + token.substring(token.length()
- 4);
+ }
+
+ private SubjectSum requireCurrentSubject() throws AuthenticationException {
+ SubjectSum subjectSum = SurenessContextHolder.getBindSubject();
+ if (subjectSum == null) {
+ throw new AuthenticationException("No login user");
+ }
+ return subjectSum;
+ }
+
+ private String getCurrentUserId(SubjectSum subjectSum) {
+ Object principal = subjectSum.getPrincipal();
+ return principal == null ? null : String.valueOf(principal);
}
}
diff --git
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java
new file mode 100644
index 0000000000..36414d88f2
--- /dev/null
+++
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/config/ApiTokenValidationFilterTest.java
@@ -0,0 +1,263 @@
+/*
+ * 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.hertzbeat.manager.config;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.mockStatic;
+
+import com.usthe.sureness.subject.PrincipalMap;
+import com.usthe.sureness.subject.SubjectSum;
+import com.usthe.sureness.util.SurenessContextHolder;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import java.io.PrintWriter;
+import java.io.StringWriter;
+import java.util.List;
+import org.apache.hertzbeat.common.constants.NetworkConstants;
+import org.apache.hertzbeat.manager.service.AccountService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+/**
+ * Test case for {@link ApiTokenValidationFilter}
+ */
+@ExtendWith(MockitoExtension.class)
+class ApiTokenValidationFilterTest {
+
+ private ApiTokenValidationFilter filter;
+
+ @Mock
+ private AccountService accountService;
+
+ @Mock
+ private HttpServletRequest request;
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Mock
+ private PrincipalMap principalMap;
+
+ @BeforeEach
+ void setUp() {
+ filter = new ApiTokenValidationFilter(accountService);
+ }
+
+ @Test
+ void testNoAuthorizationHeaderPassesThrough() throws Exception {
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn(null);
+ SubjectSum subject = mockManagedSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ }
+ }
+
+ @Test
+ void testNonBearerAuthorizationPassesThrough() throws Exception {
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Basic
dXNlcjpwYXNz");
+ SubjectSum subject = mockManagedSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ }
+ }
+
+ @Test
+ void testLegacyTokenPassesThrough() throws Exception {
+ SubjectSum subject = mockSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ verify(accountService, never()).checkTokenStatus(any());
+ }
+ }
+
+ @Test
+ void testManagedTokenActivePassesThrough() throws Exception {
+ String managedToken = "managed-token";
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " +
managedToken);
+ when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+ when(accountService.checkManagedTokenAccess("admin",
List.of("admin"))).thenReturn(null);
+ doNothing().when(accountService).touchTokenLastUsedTime(managedToken);
+ SubjectSum subject = mockManagedSubjectWithClaims();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ verify(accountService).checkTokenStatus(managedToken);
+ verify(accountService).checkManagedTokenAccess("admin",
List.of("admin"));
+ verify(accountService).touchTokenLastUsedTime(managedToken);
+ }
+ }
+
+ @Test
+ void testManagedTokenRevokedRejected() throws Exception {
+ String revokedToken = "revoked-token";
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " +
revokedToken);
+ when(accountService.checkTokenStatus(revokedToken)).thenReturn("Token
has been revoked");
+
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+ SubjectSum subject = mockManagedSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request,
response, new Object()));
+ verify(response).setStatus(401);
+ }
+ }
+
+ @Test
+ void testManagedTokenStatusCheckFailureRejectsRequest() throws Exception {
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer
managed-token");
+ when(accountService.checkTokenStatus("managed-token")).thenThrow(new
RuntimeException("DB down"));
+
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+ SubjectSum subject = mockManagedSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request,
response, new Object()));
+ verify(response).setStatus(503);
+ verify(accountService, never()).touchTokenLastUsedTime(any());
+ }
+ }
+
+ @Test
+ void testManagedTokenOutdatedRolesRejected() throws Exception {
+ String managedToken = "managed-token";
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " +
managedToken);
+ when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+ when(accountService.checkManagedTokenAccess("admin", List.of("admin")))
+ .thenReturn("Token permissions are outdated");
+
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+ SubjectSum subject = mockManagedSubjectWithClaims();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request,
response, new Object()));
+ verify(response).setStatus(401);
+ verify(accountService,
never()).touchTokenLastUsedTime(managedToken);
+ }
+ }
+
+ @Test
+ void testEmptyBearerTokenPassesThrough() throws Exception {
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer ");
+ SubjectSum subject = mockManagedSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ }
+ }
+
+ @Test
+ void testTouchLastUsedTimeFailureDoesNotRejectRequest() throws Exception {
+ String managedToken = "managed-token";
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " +
managedToken);
+ when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+ when(accountService.checkManagedTokenAccess("admin",
List.of("admin"))).thenReturn(null);
+ // touchTokenLastUsedTime throws exception
+ org.mockito.Mockito.doThrow(new RuntimeException("DB error"))
+ .when(accountService).touchTokenLastUsedTime(managedToken);
+ SubjectSum subject = mockManagedSubjectWithClaims();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ }
+ }
+
+ @Test
+ void testUnauthenticatedRequestSkipsValidation() throws Exception {
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(null);
+
+
org.junit.jupiter.api.Assertions.assertTrue(filter.preHandle(request, response,
new Object()));
+ verify(accountService, never()).checkTokenStatus(any());
+ }
+ }
+
+ @Test
+ void testManagedTokenAccountValidationFailureRejectsRequest() throws
Exception {
+ String managedToken = "managed-token";
+
when(request.getHeader(NetworkConstants.AUTHORIZATION)).thenReturn("Bearer " +
managedToken);
+ when(accountService.checkTokenStatus(managedToken)).thenReturn(null);
+ when(accountService.checkManagedTokenAccess("admin", List.of("admin")))
+ .thenThrow(new RuntimeException("account store unavailable"));
+
+ StringWriter stringWriter = new StringWriter();
+ PrintWriter printWriter = new PrintWriter(stringWriter);
+ when(response.getWriter()).thenReturn(printWriter);
+ SubjectSum subject = mockManagedSubjectWithClaims();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subject);
+
+
org.junit.jupiter.api.Assertions.assertFalse(filter.preHandle(request,
response, new Object()));
+ verify(response).setStatus(503);
+ verify(accountService,
never()).touchTokenLastUsedTime(managedToken);
+ }
+ }
+
+ private SubjectSum mockSubject() {
+ SubjectSum subjectSum = org.mockito.Mockito.mock(SubjectSum.class);
+ when(subjectSum.getPrincipalMap()).thenReturn(principalMap);
+ return subjectSum;
+ }
+
+ private SubjectSum mockManagedSubject() {
+ SubjectSum subjectSum = mockSubject();
+ when(principalMap.getPrincipal("managed")).thenReturn(true);
+ return subjectSum;
+ }
+
+ private SubjectSum mockManagedSubjectWithClaims() {
+ SubjectSum subjectSum = mockManagedSubject();
+ when(subjectSum.getPrincipal()).thenReturn("admin");
+ when(subjectSum.getRoles()).thenReturn(List.of("admin"));
+ return subjectSum;
+ }
+}
diff --git
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java
new file mode 100644
index 0000000000..58910869dc
--- /dev/null
+++
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/controller/AuthTokenControllerTest.java
@@ -0,0 +1,226 @@
+/*
+ * 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.hertzbeat.manager.controller;
+
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.doNothing;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
+import static
org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
+
+import com.usthe.sureness.subject.SubjectSum;
+import com.usthe.sureness.util.SurenessContextHolder;
+import java.time.LocalDateTime;
+import java.util.List;
+import org.apache.hertzbeat.common.constants.CommonConstants;
+import org.apache.hertzbeat.common.entity.manager.AuthToken;
+import org.apache.hertzbeat.manager.service.AccountService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.test.web.servlet.MockMvc;
+import org.springframework.test.web.servlet.request.MockMvcRequestBuilders;
+import org.springframework.test.web.servlet.setup.MockMvcBuilders;
+
+/**
+ * Test case for {@link AuthTokenController}
+ */
+@ExtendWith(MockitoExtension.class)
+class AuthTokenControllerTest {
+
+ private MockMvc mockMvc;
+
+ @InjectMocks
+ private AuthTokenController authTokenController;
+
+ @Mock
+ private AccountService accountService;
+
+ @BeforeEach
+ void setUp() {
+ this.mockMvc =
MockMvcBuilders.standaloneSetup(authTokenController).build();
+ }
+
+ @Test
+ void testGenerateTokenSuccess() throws Exception {
+ SubjectSum subjectSum = mockAdminSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+ when(accountService.generateToken(eq("my-token"),
eq(3600L))).thenReturn("generated-jwt-token");
+
+
this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate")
+ .param("name", "my-token")
+ .param("expireSeconds", "3600"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.SUCCESS_CODE))
+
.andExpect(jsonPath("$.data.token").value("generated-jwt-token"));
+ }
+ }
+
+ @Test
+ void testGenerateTokenWithoutOptionalParams() throws Exception {
+ SubjectSum subjectSum = mockAdminSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+ when(accountService.generateToken(any(),
any())).thenReturn("generated-jwt-token");
+
+
this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.SUCCESS_CODE))
+
.andExpect(jsonPath("$.data.token").value("generated-jwt-token"));
+ }
+ }
+
+ @Test
+ void testGenerateTokenNoLogin() throws Exception {
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(null);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.FAIL_CODE))
+ .andExpect(jsonPath("$.msg").value("No login user"));
+ }
+ }
+
+ @Test
+ void testGenerateTokenNoPermission() throws Exception {
+ SubjectSum subjectSum = mockUserSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.post("/api/account/token/generate"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.FAIL_CODE))
+ .andExpect(jsonPath("$.msg").value("No permission"));
+ }
+ }
+
+ @Test
+ void testListTokensSuccess() throws Exception {
+ SubjectSum subjectSum = mockAdminSubject();
+ List<AuthToken> tokens = List.of(
+
AuthToken.builder().id(1L).name("Token1").tokenHash("hash1").tokenMask("mask1")
+ .status((byte)
0).gmtCreate(LocalDateTime.now()).build(),
+
AuthToken.builder().id(2L).name("Token2").tokenHash("hash2").tokenMask("mask2")
+ .status((byte)
0).gmtCreate(LocalDateTime.now()).build()
+ );
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+ when(accountService.listTokens()).thenReturn(tokens);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.get("/api/account/token"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.SUCCESS_CODE))
+ .andExpect(jsonPath("$.data.length()").value(2))
+ .andExpect(jsonPath("$.data[0].name").value("Token1"))
+ .andExpect(jsonPath("$.data[1].name").value("Token2"));
+ }
+ }
+
+ @Test
+ void testListTokensNoLogin() throws Exception {
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(null);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.get("/api/account/token"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.FAIL_CODE));
+ }
+ }
+
+ @Test
+ void testListTokensNoPermission() throws Exception {
+ SubjectSum subjectSum = mockUserSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.get("/api/account/token"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.FAIL_CODE))
+ .andExpect(jsonPath("$.msg").value("No permission"));
+ }
+ }
+
+ @Test
+ void testDeleteTokenSuccess() throws Exception {
+ SubjectSum subjectSum = mockAdminSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+ doNothing().when(accountService).deleteToken(1L);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.delete("/api/account/token/1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.SUCCESS_CODE));
+
+ verify(accountService).deleteToken(1L);
+ }
+ }
+
+ @Test
+ void testDeleteTokenNoLogin() throws Exception {
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(null);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.delete("/api/account/token/1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.FAIL_CODE));
+ }
+ }
+
+ @Test
+ void testDeleteTokenNoPermission() throws Exception {
+ SubjectSum subjectSum = mockUserSubject();
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+
this.mockMvc.perform(MockMvcRequestBuilders.delete("/api/account/token/1"))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.code").value((int)
CommonConstants.FAIL_CODE))
+ .andExpect(jsonPath("$.msg").value("No permission"));
+
+ verify(accountService, never()).deleteToken(1L);
+ }
+ }
+
+ private SubjectSum mockAdminSubject() {
+ SubjectSum subjectSum = org.mockito.Mockito.mock(SubjectSum.class);
+ when(subjectSum.hasRole("admin")).thenReturn(true);
+ return subjectSum;
+ }
+
+ private SubjectSum mockUserSubject() {
+ SubjectSum subjectSum = org.mockito.Mockito.mock(SubjectSum.class);
+ when(subjectSum.hasRole("admin")).thenReturn(false);
+ return subjectSum;
+ }
+}
diff --git
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
index 3848af58c4..5f1d95d811 100644
---
a/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
+++
b/hertzbeat-manager/src/test/java/org/apache/hertzbeat/manager/service/AccountServiceTest.java
@@ -17,12 +17,6 @@
package org.apache.hertzbeat.manager.service;
-import static org.junit.jupiter.api.Assertions.assertEquals;
-import static org.junit.jupiter.api.Assertions.assertNotNull;
-import static org.junit.jupiter.api.Assertions.assertNull;
-import static org.mockito.Mockito.mockStatic;
-import static org.mockito.Mockito.mock;
-import static org.mockito.Mockito.when;
import com.usthe.sureness.provider.DefaultAccount;
import com.usthe.sureness.provider.SurenessAccount;
import com.usthe.sureness.provider.SurenessAccountProvider;
@@ -32,17 +26,36 @@ import com.usthe.sureness.util.Md5Util;
import com.usthe.sureness.util.SurenessContextHolder;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.MalformedJwtException;
-import java.util.Collections;
-import java.util.List;
-import java.util.Map;
-import javax.naming.AuthenticationException;
+import org.apache.hertzbeat.common.entity.manager.AuthToken;
import org.apache.hertzbeat.common.util.JsonUtil;
+import org.apache.hertzbeat.manager.dao.AuthTokenDao;
import org.apache.hertzbeat.manager.pojo.dto.LoginDto;
import org.apache.hertzbeat.manager.pojo.dto.RefreshTokenResponse;
import org.apache.hertzbeat.manager.service.impl.AccountServiceImpl;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+import org.springframework.test.util.ReflectionTestUtils;
+
+import javax.naming.AuthenticationException;
+import java.time.LocalDateTime;
+import java.util.Collections;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.mockStatic;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
/**
* test case for {@link AccountServiceImpl}
@@ -54,6 +67,8 @@ class AccountServiceTest {
private SurenessAccountProvider accountProvider;
+ private AuthTokenDao authTokenDao;
+
private final String identifier = "admin";
private final String password = "hertzbeat";
private final String salt = "salt1";
@@ -70,7 +85,9 @@ class AccountServiceTest {
void setUp() {
accountProvider = mock(SurenessAccountProvider.class);
+ authTokenDao = mock(AuthTokenDao.class);
accountService = new AccountServiceImpl(accountProvider);
+ ReflectionTestUtils.setField(accountService, "authTokenDao",
authTokenDao);
JsonWebTokenUtil.setDefaultSecretKey(jwt);
}
@@ -186,13 +203,14 @@ class AccountServiceTest {
.setExcessiveAttempts(Boolean.FALSE)
.build();
when(accountProvider.loadAccount(identifier)).thenReturn(account);
+ when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation ->
invocation.getArgument(0));
SubjectSum subjectSum = mock(SubjectSum.class);
when(subjectSum.getPrincipal()).thenReturn(identifier);
try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
- String token = accountService.generateToken();
+ String token = accountService.generateToken(null, null);
assertNull(JsonWebTokenUtil.parseJwt(token).get("refresh",
Boolean.class));
Assertions.assertThrows(
@@ -220,7 +238,7 @@ class AccountServiceTest {
Assertions.assertThrows(
AuthenticationException.class,
- () -> accountService.generateToken()
+ () -> accountService.generateToken(null, null)
);
}
}
@@ -236,4 +254,305 @@ class AccountServiceTest {
);
}
+ // ==================== Token Management Tests ====================
+
+ @Test
+ void testGenerateTokenPersistsToDb() throws Exception {
+ SurenessAccount account = buildActiveAccount();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+ when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ String token = accountService.generateToken("My Token", null);
+
+ assertNotNull(token);
+ ArgumentCaptor<AuthToken> captor =
ArgumentCaptor.forClass(AuthToken.class);
+ verify(authTokenDao).save(captor.capture());
+ AuthToken saved = captor.getValue();
+ assertEquals("My Token", saved.getName());
+ assertNotNull(saved.getTokenHash());
+ assertNotNull(saved.getTokenMask());
+ assertEquals((byte) 0, saved.getStatus());
+ assertEquals(identifier, saved.getCreator());
+ assertNull(saved.getExpireTime());
+ }
+ }
+
+ @Test
+ void testGenerateTokenNullName() throws Exception {
+ SurenessAccount account = buildActiveAccount();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+ when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ accountService.generateToken(null, null);
+
+ ArgumentCaptor<AuthToken> captor =
ArgumentCaptor.forClass(AuthToken.class);
+ verify(authTokenDao).save(captor.capture());
+ assertNull(captor.getValue().getName());
+ }
+ }
+
+ @Test
+ void testGenerateTokenWithExpiration() throws Exception {
+ SurenessAccount account = buildActiveAccount();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+ when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ LocalDateTime before = LocalDateTime.now();
+ accountService.generateToken("Expiring Token", 7200L);
+
+ ArgumentCaptor<AuthToken> captor =
ArgumentCaptor.forClass(AuthToken.class);
+ verify(authTokenDao).save(captor.capture());
+ AuthToken saved = captor.getValue();
+ assertNotNull(saved.getExpireTime());
+
assertTrue(saved.getExpireTime().isAfter(before.plusSeconds(7100)));
+ }
+ }
+
+ @Test
+ void testGenerateTokenHasManagedClaim() throws Exception {
+ SurenessAccount account = buildActiveAccount();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+ when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ String token = accountService.generateToken("test", null);
+
+ Claims claims = JsonWebTokenUtil.parseJwt(token);
+ assertEquals(Boolean.TRUE,
claims.get(AccountServiceImpl.CLAIM_MANAGED, Boolean.class));
+ }
+ }
+
+ @Test
+ void testListTokensForAdmin() {
+ List<AuthToken> expected = List.of(
+ AuthToken.builder().id(1L).name("Token1").build(),
+ AuthToken.builder().id(2L).name("Token2").build()
+ );
+ when(authTokenDao.findByStatus((byte) 0)).thenReturn(expected);
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ List<AuthToken> result = accountService.listTokens();
+
+ assertEquals(2, result.size());
+ assertEquals("Token1", result.get(0).getName());
+ }
+ }
+
+ @Test
+ void testListTokensForCurrentUser() {
+ List<AuthToken> expected = List.of(
+
AuthToken.builder().id(1L).name("Token1").creator("tom").build()
+ );
+ when(authTokenDao.findByStatusAndCreator((byte) 0,
"tom")).thenReturn(expected);
+ SubjectSum subjectSum = mockUserSubject("tom");
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ List<AuthToken> result = accountService.listTokens();
+
+ assertEquals(1, result.size());
+ assertEquals("Token1", result.get(0).getName());
+ verify(authTokenDao).findByStatusAndCreator((byte) 0, "tom");
+ }
+ }
+
+ @Test
+ void testDeleteTokenInvalidatesCache() throws Exception {
+ AuthToken token =
AuthToken.builder().id(1L).tokenHash("hash123").creator(identifier).build();
+ when(authTokenDao.findById(1L)).thenReturn(Optional.of(token));
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ accountService.deleteToken(1L);
+ }
+
+ verify(authTokenDao).deleteById(1L);
+ }
+
+ @Test
+ void testDeleteTokenRejectsNonOwner() {
+ AuthToken token =
AuthToken.builder().id(1L).tokenHash("hash123").creator("admin").build();
+ when(authTokenDao.findById(1L)).thenReturn(Optional.of(token));
+ SubjectSum subjectSum = mockUserSubject("tom");
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ Assertions.assertThrows(AuthenticationException.class, () ->
accountService.deleteToken(1L));
+ }
+ }
+
+ @Test
+ void testCheckTokenStatusActive() throws Exception {
+ SurenessAccount account = buildActiveAccount();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+ when(authTokenDao.save(any(AuthToken.class))).thenAnswer(invocation ->
invocation.getArgument(0));
+ SubjectSum subjectSum = mock(SubjectSum.class);
+ when(subjectSum.getPrincipal()).thenReturn(identifier);
+
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+
+ String token = accountService.generateToken("test", null);
+ when(authTokenDao.existsByTokenHashAndStatus(any(String.class),
eq((byte) 0))).thenReturn(true);
+
+ String result = accountService.checkTokenStatus(token);
+
+ assertNull(result);
+ }
+ }
+
+ @Test
+ void testCheckTokenStatusRevoked() {
+ when(authTokenDao.existsByTokenHashAndStatus(any(String.class),
eq((byte) 0))).thenReturn(false);
+
+ String result = accountService.checkTokenStatus("some-revoked-token");
+
+ assertEquals("Token has been revoked", result);
+ }
+
+ @Test
+ void testCheckTokenStatusUsesCache() {
+ when(authTokenDao.existsByTokenHashAndStatus(any(String.class),
eq((byte) 0))).thenReturn(true);
+
+ // First call - hits DB
+ assertNull(accountService.checkTokenStatus("cached-token"));
+ // Second call - should use cache, no additional DB call
+ assertNull(accountService.checkTokenStatus("cached-token"));
+
+ verify(authTokenDao,
times(1)).existsByTokenHashAndStatus(any(String.class), eq((byte) 0));
+ }
+
+ @Test
+ void testCheckManagedTokenAccessValid() {
+
when(accountProvider.loadAccount(identifier)).thenReturn(buildActiveAccount());
+
+ String result = accountService.checkManagedTokenAccess(identifier,
List.of("admin"));
+
+ assertNull(result);
+ }
+
+ @Test
+ void testCheckManagedTokenAccessRejectsDisabledAccount() {
+ SurenessAccount account = DefaultAccount.builder("app1")
+ .setPassword(Md5Util.md5(password + salt))
+ .setSalt(salt)
+ .setOwnRoles(roles)
+ .setDisabledAccount(Boolean.TRUE)
+ .setExcessiveAttempts(Boolean.FALSE)
+ .build();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+
+ String result = accountService.checkManagedTokenAccess(identifier,
List.of("admin"));
+
+ assertEquals("Token owner account is no longer valid", result);
+ }
+
+ @Test
+ void testCheckManagedTokenAccessRejectsOutdatedRoles() {
+ SurenessAccount account = DefaultAccount.builder("app1")
+ .setPassword(Md5Util.md5(password + salt))
+ .setSalt(salt)
+ .setOwnRoles(List.of("guest"))
+ .setDisabledAccount(Boolean.FALSE)
+ .setExcessiveAttempts(Boolean.FALSE)
+ .build();
+ when(accountProvider.loadAccount(identifier)).thenReturn(account);
+
+ String result = accountService.checkManagedTokenAccess(identifier,
List.of("admin"));
+
+ assertEquals("Token permissions are outdated", result);
+ }
+
+ @Test
+ void testTouchTokenLastUsedTime() {
+ accountService.touchTokenLastUsedTime("some-token-value");
+
+ verify(authTokenDao).updateLastUsedTime(any(String.class),
any(LocalDateTime.class));
+ }
+
+ @Test
+ void testTouchTokenLastUsedTimeThrottled() {
+ // First call - should write to DB
+ accountService.touchTokenLastUsedTime("throttle-token");
+ // Second call within 5 minutes - should be throttled
+ accountService.touchTokenLastUsedTime("throttle-token");
+
+ verify(authTokenDao, times(1)).updateLastUsedTime(any(String.class),
any(LocalDateTime.class));
+ }
+
+ @Test
+ void testTouchTokenLastUsedTimeDifferentTokensNotThrottled() {
+ accountService.touchTokenLastUsedTime("token-a");
+ accountService.touchTokenLastUsedTime("token-b");
+
+ verify(authTokenDao, times(2)).updateLastUsedTime(any(String.class),
any(LocalDateTime.class));
+ }
+
+ @Test
+ void testDeleteTokenCacheInvalidation() throws Exception {
+ // Setup: token is active and cached
+ String tokenValue = "token-to-revoke";
+ when(authTokenDao.existsByTokenHashAndStatus(any(String.class),
eq((byte) 0))).thenReturn(true);
+ assertNull(accountService.checkTokenStatus(tokenValue));
+
+ // Now revoke it
+ AuthToken authToken =
AuthToken.builder().id(1L).tokenHash("some-hash").creator(identifier).build();
+ when(authTokenDao.findById(1L)).thenReturn(Optional.of(authToken));
+ SubjectSum subjectSum = mockAdminSubject(identifier);
+ try (var mockedStatic = mockStatic(SurenessContextHolder.class)) {
+
mockedStatic.when(SurenessContextHolder::getBindSubject).thenReturn(subjectSum);
+ accountService.deleteToken(1L);
+ }
+
+ // After revoke, the next check should hit DB again (cache invalidated
for that hash)
+ // Note: the tokenHash in DB differs from sha256(tokenValue), so this
tests cache invalidation path
+ verify(authTokenDao).findById(1L);
+ verify(authTokenDao).deleteById(1L);
+ }
+
+ private SurenessAccount buildActiveAccount() {
+ return DefaultAccount.builder("app1")
+ .setPassword(Md5Util.md5(password + salt))
+ .setSalt(salt)
+ .setOwnRoles(roles)
+ .setDisabledAccount(Boolean.FALSE)
+ .setExcessiveAttempts(Boolean.FALSE)
+ .build();
+ }
+
+ private SubjectSum mockAdminSubject(String principal) {
+ SubjectSum subjectSum = mock(SubjectSum.class);
+ when(subjectSum.getPrincipal()).thenReturn(principal);
+ when(subjectSum.hasRole("admin")).thenReturn(true);
+ return subjectSum;
+ }
+
+ private SubjectSum mockUserSubject(String principal) {
+ SubjectSum subjectSum = mock(SubjectSum.class);
+ when(subjectSum.getPrincipal()).thenReturn(principal);
+ when(subjectSum.hasRole("admin")).thenReturn(false);
+ return subjectSum;
+ }
}
diff --git a/hertzbeat-startup/src/main/resources/sureness.yml
b/hertzbeat-startup/src/main/resources/sureness.yml
index 282e93928a..d366fe8a02 100644
--- a/hertzbeat-startup/src/main/resources/sureness.yml
+++ b/hertzbeat-startup/src/main/resources/sureness.yml
@@ -71,6 +71,9 @@ resourceRole:
- /api/chat/**===get===[admin,user]
- /api/chat/**===post===[admin]
- /api/logs/ingest/**===post===[admin,user]
+ - /api/account/token===get===[admin]
+ - /api/account/token/**===post===[admin]
+ - /api/account/token/**===delete===[admin]
# config the resource restful api that need bypass auth protection
# rule: api===method
diff --git
a/web-app/src/app/routes/alert/alert-integration/alert-integration.component.html
b/web-app/src/app/routes/alert/alert-integration/alert-integration.component.html
index f3ae43e0fd..0d6fc7276c 100644
---
a/web-app/src/app/routes/alert/alert-integration/alert-integration.component.html
+++
b/web-app/src/app/routes/alert/alert-integration/alert-integration.component.html
@@ -43,43 +43,14 @@
</div>
<div class="doc-content">
<ng-container *ngIf="selectedSource">
- <div style="display: flex; justify-content: space-between">
+ <div style="display: flex; justify-content: space-between; align-items:
center">
<h2>{{ selectedSource.name }}</h2>
- <button [nzLoading]="generateLoading" nz-button nzType="primary"
(click)="generateToken()" style="margin-right: 16px">{{
- 'alert.integration.token.new' | i18n
- }}</button>
+ <button nz-button nzType="default" (click)="goToTokenManagement()"
style="margin-right: 16px">
+ <span nz-icon nzType="setting" nzTheme="outline"></span>
+ {{ 'alert.integration.token.manage' | i18n }}
+ </button>
</div>
<markdown [data]="markdownContent"></markdown>
</ng-container>
</div>
</div>
-
-<nz-modal
- [(nzVisible)]="isModalVisible"
- nzMaskClosable="false"
- nzWidth="40%"
- [nzTitle]="'alert.integration.token.title' | i18n"
- (nzOnCancel)="handleCancel()"
- (nzOnOk)="handleOk()"
- [nzOkText]="'common.button.confirm' | i18n"
- [nzCancelText]="null"
->
- <div *nzModalContent class="-inner-content">
- <div>
- <p>{{ 'alert.integration.token.desc' | i18n }}</p>
- </div>
- <div style="width: 100%">
- <div style="display: flex; justify-content: flex-end; margin-bottom:
8px">
- <button nz-button nzType="primary" (click)="copyToken()">
- {{ 'common.button.copy' | i18n }}
- </button>
- </div>
- <div style="padding: 12px; border: 1px solid; border-radius: 4px;
word-wrap: break-word">
- {{ token }}
- </div>
- </div>
- <div style="text-align: center; font-weight: lighter; margin-top: 16px">
- <p>{{ 'alert.integration.token.notice' | i18n }}</p>
- </div>
- </div>
-</nz-modal>
diff --git
a/web-app/src/app/routes/alert/alert-integration/alert-integration.component.ts
b/web-app/src/app/routes/alert/alert-integration/alert-integration.component.ts
index d69174b0c3..57eab71d8f 100644
---
a/web-app/src/app/routes/alert/alert-integration/alert-integration.component.ts
+++
b/web-app/src/app/routes/alert/alert-integration/alert-integration.component.ts
@@ -25,12 +25,9 @@ import { I18NService } from '@core';
import { ALAIN_I18N_TOKEN, I18nPipe } from '@delon/theme';
import { SharedModule } from '@shared';
import { NzDividerComponent } from 'ng-zorro-antd/divider';
-import { NzModalService } from 'ng-zorro-antd/modal';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { MarkdownModule } from 'ngx-markdown';
-import { AuthService } from '../../../service/auth.service';
-
interface DataSource {
id: string;
name: string;
@@ -102,16 +99,11 @@ export class AlertIntegrationComponent implements OnInit {
selectedSource: DataSource | null = null;
markdownContent: string = '';
- token: string = '';
- isModalVisible: boolean = false;
- generateLoading: boolean = false;
constructor(
private http: HttpClient,
- private authSvc: AuthService,
@Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService,
private notifySvc: NzNotificationService,
- private modal: NzModalService,
private router: Router,
private route: ActivatedRoute
) {}
@@ -147,27 +139,8 @@ export class AlertIntegrationComponent implements OnInit {
this.router.navigate(['/alert/integration', source.id]);
}
- public generateToken() {
- this.generateLoading = true;
- this.authSvc.generateToken().subscribe(message => {
- if (message.code === 0) {
- this.token = message.data?.token;
- this.isModalVisible = true;
- } else {
- this.notifySvc.warning('Failed to generate token', message.msg);
- }
- this.generateLoading = false;
- });
- }
-
- handleCancel(): void {
- this.isModalVisible = false;
- this.token = '';
- }
-
- handleOk(): void {
- this.isModalVisible = false;
- this.token = '';
+ goToTokenManagement(): void {
+ this.router.navigateByUrl('/setting/settings/token');
}
private loadMarkdownContent(source: DataSource) {
@@ -184,14 +157,4 @@ export class AlertIntegrationComponent implements OnInit {
}
});
}
-
- copyToken() {
- const el = document.createElement('textarea');
- el.value = this.token;
- document.body.appendChild(el);
- el.select();
- document.execCommand('copy');
- document.body.removeChild(el);
- this.notifySvc.success(this.i18nSvc.fanyi('common.notify.copy-success'),
this.i18nSvc.fanyi('alert.integration.token.notice'));
- }
}
diff --git
a/web-app/src/app/routes/log/log-integration/log-integration.component.html
b/web-app/src/app/routes/log/log-integration/log-integration.component.html
index 9e3deba70f..d8525db5cd 100644
--- a/web-app/src/app/routes/log/log-integration/log-integration.component.html
+++ b/web-app/src/app/routes/log/log-integration/log-integration.component.html
@@ -43,43 +43,14 @@
</div>
<div class="doc-content">
<ng-container *ngIf="selectedSource">
- <div style="display: flex; justify-content: space-between">
+ <div style="display: flex; justify-content: space-between; align-items:
center">
<h2>{{ selectedSource.name }}</h2>
- <button [nzLoading]="generateLoading" nz-button nzType="primary"
(click)="generateToken()" style="margin-right: 16px">{{
- 'log.integration.token.new' | i18n
- }}</button>
+ <button nz-button nzType="default" (click)="goToTokenManagement()"
style="margin-right: 16px">
+ <span nz-icon nzType="setting" nzTheme="outline"></span>
+ {{ 'log.integration.token.manage' | i18n }}
+ </button>
</div>
<markdown [data]="markdownContent"></markdown>
</ng-container>
</div>
</div>
-
-<nz-modal
- [(nzVisible)]="isModalVisible"
- nzMaskClosable="false"
- nzWidth="40%"
- [nzTitle]="'log.integration.token.title' | i18n"
- (nzOnCancel)="handleCancel()"
- (nzOnOk)="handleOk()"
- [nzOkText]="'common.button.confirm' | i18n"
- [nzCancelText]="null"
->
- <div *nzModalContent class="-inner-content">
- <div>
- <p>{{ 'log.integration.token.desc' | i18n }}</p>
- </div>
- <div style="width: 100%">
- <div style="display: flex; justify-content: flex-end; margin-bottom:
8px">
- <button nz-button nzType="primary" (click)="copyToken()">
- {{ 'common.button.copy' | i18n }}
- </button>
- </div>
- <div style="padding: 12px; border: 1px solid; border-radius: 4px;
word-wrap: break-word">
- {{ token }}
- </div>
- </div>
- <div style="text-align: center; font-weight: lighter; margin-top: 16px">
- <p>{{ 'log.integration.token.notice' | i18n }}</p>
- </div>
- </div>
-</nz-modal>
diff --git
a/web-app/src/app/routes/log/log-integration/log-integration.component.ts
b/web-app/src/app/routes/log/log-integration/log-integration.component.ts
index 8ca1b9de05..d80408617d 100644
--- a/web-app/src/app/routes/log/log-integration/log-integration.component.ts
+++ b/web-app/src/app/routes/log/log-integration/log-integration.component.ts
@@ -25,12 +25,9 @@ import { I18NService } from '@core';
import { ALAIN_I18N_TOKEN, I18nPipe } from '@delon/theme';
import { SharedModule } from '@shared';
import { NzDividerComponent } from 'ng-zorro-antd/divider';
-import { NzModalService } from 'ng-zorro-antd/modal';
import { NzNotificationService } from 'ng-zorro-antd/notification';
import { MarkdownModule } from 'ngx-markdown';
-import { AuthService } from '../../../service/auth.service';
-
interface DataSource {
id: string;
name: string;
@@ -57,16 +54,11 @@ export class LogIntegrationComponent implements OnInit {
selectedSource: DataSource | null = null;
markdownContent: string = '';
- token: string = '';
- isModalVisible: boolean = false;
- generateLoading: boolean = false;
constructor(
private http: HttpClient,
- private authSvc: AuthService,
@Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService,
private notifySvc: NzNotificationService,
- private modal: NzModalService,
private router: Router,
private route: ActivatedRoute
) {}
@@ -102,27 +94,8 @@ export class LogIntegrationComponent implements OnInit {
this.router.navigate(['/log/integration', source.id]);
}
- public generateToken() {
- this.generateLoading = true;
- this.authSvc.generateToken().subscribe(message => {
- if (message.code === 0) {
- this.token = message.data?.token;
- this.isModalVisible = true;
- } else {
- this.notifySvc.warning('Failed to generate token', message.msg);
- }
- this.generateLoading = false;
- });
- }
-
- handleCancel(): void {
- this.isModalVisible = false;
- this.token = '';
- }
-
- handleOk(): void {
- this.isModalVisible = false;
- this.token = '';
+ goToTokenManagement(): void {
+ this.router.navigateByUrl('/setting/settings/token');
}
private loadMarkdownContent(source: DataSource) {
@@ -139,14 +112,4 @@ export class LogIntegrationComponent implements OnInit {
}
});
}
-
- copyToken() {
- const el = document.createElement('textarea');
- el.value = this.token;
- document.body.appendChild(el);
- el.select();
- document.execCommand('copy');
- document.body.removeChild(el);
- this.notifySvc.success(this.i18nSvc.fanyi('common.notify.copy-success'),
this.i18nSvc.fanyi('log.integration.token.notice'));
- }
}
diff --git a/web-app/src/app/routes/setting/setting-routing.module.ts
b/web-app/src/app/routes/setting/setting-routing.module.ts
index 11c1078366..8c17c607dc 100644
--- a/web-app/src/app/routes/setting/setting-routing.module.ts
+++ b/web-app/src/app/routes/setting/setting-routing.module.ts
@@ -28,6 +28,7 @@ import { MessageServerComponent } from
'./settings/message-server/message-server
import { ObjectStoreComponent } from
'./settings/object-store/object-store.component';
import { SettingsComponent } from './settings/settings.component';
import { SystemConfigComponent } from
'./settings/system-config/system-config.component';
+import { TokenManagementComponent } from
'./settings/token-management/token-management.component';
import { StatusComponent } from './status/status.component';
const routes: Routes = [
@@ -55,6 +56,11 @@ const routes: Routes = [
path: 'object-store',
component: ObjectStoreComponent,
data: { titleI18n: 'settings.object-store' }
+ },
+ {
+ path: 'token',
+ component: TokenManagementComponent,
+ data: { titleI18n: 'settings.token' }
}
]
}
diff --git a/web-app/src/app/routes/setting/setting.module.ts
b/web-app/src/app/routes/setting/setting.module.ts
index 38dbf55bbf..1faa09722c 100644
--- a/web-app/src/app/routes/setting/setting.module.ts
+++ b/web-app/src/app/routes/setting/setting.module.ts
@@ -27,6 +27,7 @@ import { NzCodeEditorModule } from
'ng-zorro-antd/code-editor';
import { NzCollapseModule } from 'ng-zorro-antd/collapse';
import { NzDividerModule } from 'ng-zorro-antd/divider';
import { NzDropDownModule } from 'ng-zorro-antd/dropdown';
+import { NzEmptyModule } from 'ng-zorro-antd/empty';
import { NzLayoutModule } from 'ng-zorro-antd/layout';
import { NzListModule } from 'ng-zorro-antd/list';
import { NzPaginationComponent } from 'ng-zorro-antd/pagination';
@@ -45,6 +46,7 @@ import { MessageServerComponent } from
'./settings/message-server/message-server
import { ObjectStoreComponent } from
'./settings/object-store/object-store.component';
import { SettingsComponent } from './settings/settings.component';
import { SystemConfigComponent } from
'./settings/system-config/system-config.component';
+import { TokenManagementComponent } from
'./settings/token-management/token-management.component';
import { StatusComponent } from './status/status.component';
const COMPONENTS: Array<Type<void>> = [
@@ -54,6 +56,7 @@ const COMPONENTS: Array<Type<void>> = [
MessageServerComponent,
SystemConfigComponent,
ObjectStoreComponent,
+ TokenManagementComponent,
CollectorComponent,
StatusComponent,
SettingPluginsComponent
@@ -78,7 +81,8 @@ const COMPONENTS: Array<Type<void>> = [
NzRadioModule,
NzUploadComponent,
NzPaginationComponent,
- NzDropDownModule
+ NzDropDownModule,
+ NzEmptyModule
],
declarations: COMPONENTS
})
diff --git a/web-app/src/app/routes/setting/settings/settings.component.ts
b/web-app/src/app/routes/setting/settings/settings.component.ts
index 795dd6141b..c86639cc0e 100644
--- a/web-app/src/app/routes/setting/settings/settings.component.ts
+++ b/web-app/src/app/routes/setting/settings/settings.component.ts
@@ -47,6 +47,10 @@ export class SettingsComponent implements AfterViewInit,
OnDestroy {
{
key: 'object-store',
title: this.i18nSvc.fanyi('settings.object-store')
+ },
+ {
+ key: 'token',
+ title: this.i18nSvc.fanyi('settings.token')
}
];
@@ -57,6 +61,7 @@ export class SettingsComponent implements AfterViewInit,
OnDestroy {
@Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService
) {
this.router$ = this.router.events.pipe(filter(e => e instanceof
ActivationEnd)).subscribe(() => this.setActive());
+ this.setActive();
}
private setActive(): void {
diff --git
a/web-app/src/app/routes/setting/settings/token-management/token-management.component.html
b/web-app/src/app/routes/setting/settings/token-management/token-management.component.html
new file mode 100644
index 0000000000..dd47179286
--- /dev/null
+++
b/web-app/src/app/routes/setting/settings/token-management/token-management.component.html
@@ -0,0 +1,143 @@
+<!--
+ ~ 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.
+-->
+
+<nz-spin [nzSpinning]="loading">
+ <div class="token-management">
+ <div class="token-header">
+ <p class="token-desc">{{ 'settings.token.desc' | i18n }}</p>
+ <button nz-button nzType="primary" [nzLoading]="generateLoading"
(click)="showGenerateModal()">
+ <span nz-icon nzType="plus" nzTheme="outline"></span>
+ {{ 'settings.token.generate' | i18n }}
+ </button>
+ </div>
+
+ <nz-table #tokenTable [nzData]="tokens" [nzShowPagination]="false"
[nzBordered]="true" nzSize="middle">
+ <thead>
+ <tr>
+ <th>{{ 'settings.token.name' | i18n }}</th>
+ <th>{{ 'settings.token.value' | i18n }}</th>
+ <th>{{ 'settings.token.creator' | i18n }}</th>
+ <th>{{ 'settings.token.create-time' | i18n }}</th>
+ <th>{{ 'settings.token.expire-time' | i18n }}</th>
+ <th>{{ 'settings.token.last-used' | i18n }}</th>
+ <th nzWidth="120px">{{ 'common.edit' | i18n }}</th>
+ </tr>
+ </thead>
+ <tbody>
+ <tr *ngFor="let token of tokenTable.data">
+ <td>{{ token.name }}</td>
+ <td>
+ <code>{{ token.tokenMask }}</code>
+ </td>
+ <td>{{ token.creator }}</td>
+ <td>{{ token.gmtCreate | date : 'yyyy-MM-dd HH:mm:ss' }}</td>
+ <td>
+ <ng-container *ngIf="token.expireTime; else neverExpire">
+ <span [class.token-expired-text]="isExpired(token)">
+ {{ token.expireTime | date : 'yyyy-MM-dd HH:mm:ss' }}
+ </span>
+ <nz-tag *ngIf="isExpired(token)" nzColor="error"
style="margin-left: 4px">{{ 'settings.token.expired' | i18n }}</nz-tag>
+ </ng-container>
+ <ng-template #neverExpire>
+ <nz-tag nzColor="success">{{ 'settings.token.expire.never' |
i18n }}</nz-tag>
+ </ng-template>
+ </td>
+ <td>{{ token.lastUsedTime ? (token.lastUsedTime | date : 'yyyy-MM-dd
HH:mm:ss') : '-' }}</td>
+ <td>
+ <button nz-button nzType="link" nzDanger
(click)="confirmDelete(token)">
+ <span nz-icon nzType="delete" nzTheme="outline"></span>
+ {{ 'common.button.delete' | i18n }}
+ </button>
+ </td>
+ </tr>
+ </tbody>
+ </nz-table>
+
+ <div *ngIf="tokens.length === 0 && !loading" class="empty-state">
+ <nz-empty [nzNotFoundContent]="'settings.token.empty' | i18n"></nz-empty>
+ </div>
+ </div>
+</nz-spin>
+
+<!-- Generate Token Name Modal -->
+<nz-modal
+ [(nzVisible)]="isGenerateModalVisible"
+ [nzTitle]="'settings.token.generate-title' | i18n"
+ (nzOnCancel)="handleGenerateCancel()"
+ (nzOnOk)="doGenerateToken()"
+ [nzOkText]="'settings.token.generate' | i18n"
+ [nzCancelText]="'common.button.cancel' | i18n"
+>
+ <div *nzModalContent>
+ <form nz-form nzLayout="vertical">
+ <nz-form-item>
+ <nz-form-label nzRequired>{{ 'settings.token.name' | i18n
}}</nz-form-label>
+ <nz-form-control [nzErrorTip]="'validation.required' | i18n">
+ <input nz-input [(ngModel)]="newTokenName" name="tokenName" required
[placeholder]="'settings.token.name-placeholder' | i18n" />
+ </nz-form-control>
+ </nz-form-item>
+ <nz-form-item>
+ <nz-form-label>{{ 'settings.token.expire-time' | i18n
}}</nz-form-label>
+ <nz-form-control>
+ <nz-select [(ngModel)]="selectedExpire" name="expireTime">
+ <nz-option *ngFor="let opt of expireOptions"
[nzValue]="opt.seconds" [nzLabel]="opt.label"></nz-option>
+ </nz-select>
+ </nz-form-control>
+ </nz-form-item>
+ </form>
+ </div>
+</nz-modal>
+
+<!-- Token Display Modal -->
+<nz-modal
+ [(nzVisible)]="isTokenModalVisible"
+ [nzMaskClosable]="false"
+ nzWidth="50%"
+ [nzTitle]="'settings.token.generated-title' | i18n"
+ (nzOnCancel)="handleTokenModalClose()"
+ (nzOnOk)="handleTokenModalClose()"
+ [nzOkText]="'common.button.confirm' | i18n"
+ [nzCancelText]="null"
+>
+ <div *nzModalContent>
+ <nz-alert nzType="warning" [nzMessage]="'settings.token.notice' | i18n"
nzShowIcon></nz-alert>
+ <div style="margin-top: 16px">
+ <div style="display: flex; justify-content: flex-end; margin-bottom:
8px">
+ <button nz-button nzType="primary" nzSize="small"
(click)="copyToken()">
+ <span nz-icon nzType="copy" nzTheme="outline"></span>
+ {{ 'common.button.copy' | i18n }}
+ </button>
+ </div>
+ <div
+ style="
+ padding: 12px;
+ border: 1px solid #d9d9d9;
+ border-radius: 4px;
+ word-wrap: break-word;
+ word-break: break-all;
+ font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo,
monospace;
+ font-size: 13px;
+ background: #fafafa;
+ "
+ >
+ {{ generatedToken }}
+ </div>
+ </div>
+ </div>
+</nz-modal>
diff --git
a/web-app/src/app/routes/setting/settings/token-management/token-management.component.less
b/web-app/src/app/routes/setting/settings/token-management/token-management.component.less
new file mode 100644
index 0000000000..7090140c36
--- /dev/null
+++
b/web-app/src/app/routes/setting/settings/token-management/token-management.component.less
@@ -0,0 +1,26 @@
+@import '@delon/theme/index';
+
+.token-management {
+ width: 100%;
+
+ .token-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 16px;
+
+ .token-desc {
+ margin: 0;
+ color: @text-color-secondary;
+ font-size: 14px;
+ }
+ }
+
+ .empty-state {
+ padding: 48px 0;
+ }
+
+ .token-expired-text {
+ color: @error-color;
+ }
+}
\ No newline at end of file
diff --git
a/web-app/src/app/routes/setting/settings/token-management/token-management.component.ts
b/web-app/src/app/routes/setting/settings/token-management/token-management.component.ts
new file mode 100644
index 0000000000..c0dedf84c4
--- /dev/null
+++
b/web-app/src/app/routes/setting/settings/token-management/token-management.component.ts
@@ -0,0 +1,185 @@
+/*
+ * 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.
+ */
+
+import { Component, Inject, OnInit } from '@angular/core';
+import { I18NService } from '@core';
+import { ALAIN_I18N_TOKEN } from '@delon/theme';
+import { NzModalService } from 'ng-zorro-antd/modal';
+import { NzNotificationService } from 'ng-zorro-antd/notification';
+
+import { AuthService } from '../../../../service/auth.service';
+
+interface AuthToken {
+ id: number;
+ name: string;
+ tokenMask: string;
+ creator: string;
+ gmtCreate: string;
+ expireTime: string;
+ lastUsedTime: string;
+}
+
+interface ExpireOption {
+ label: string;
+ seconds: number;
+}
+
+@Component({
+ selector: 'app-token-management',
+ templateUrl: './token-management.component.html',
+ styleUrls: ['./token-management.component.less']
+})
+export class TokenManagementComponent implements OnInit {
+ tokens: AuthToken[] = [];
+ loading = false;
+ generateLoading = false;
+ isGenerateModalVisible = false;
+ isTokenModalVisible = false;
+ newTokenName = '';
+ selectedExpire: number = -1;
+ generatedToken = '';
+
+ expireOptions: ExpireOption[] = [];
+
+ constructor(
+ private authSvc: AuthService,
+ private notifySvc: NzNotificationService,
+ private modalSvc: NzModalService,
+ @Inject(ALAIN_I18N_TOKEN) private i18nSvc: I18NService
+ ) {}
+
+ ngOnInit(): void {
+ this.expireOptions = [
+ { label: this.i18nSvc.fanyi('settings.token.expire.never'), seconds: -1
},
+ { label: this.i18nSvc.fanyi('settings.token.expire.7d'), seconds: 7 * 24
* 3600 },
+ { label: this.i18nSvc.fanyi('settings.token.expire.30d'), seconds: 30 *
24 * 3600 },
+ { label: this.i18nSvc.fanyi('settings.token.expire.90d'), seconds: 90 *
24 * 3600 },
+ { label: this.i18nSvc.fanyi('settings.token.expire.180d'), seconds: 180
* 24 * 3600 },
+ { label: this.i18nSvc.fanyi('settings.token.expire.365d'), seconds: 365
* 24 * 3600 }
+ ];
+ this.loadTokens();
+ }
+
+ loadTokens(): void {
+ this.loading = true;
+ this.authSvc.listTokens().subscribe(
+ message => {
+ if (message.code === 0) {
+ this.tokens = message.data || [];
+ } else {
+ this.notifySvc.error(this.i18nSvc.fanyi('common.notify.query-fail'),
message.msg);
+ }
+ this.loading = false;
+ },
+ error => {
+ this.loading = false;
+ }
+ );
+ }
+
+ showGenerateModal(): void {
+ this.newTokenName = '';
+ this.selectedExpire = -1;
+ this.isGenerateModalVisible = true;
+ }
+
+ handleGenerateCancel(): void {
+ this.isGenerateModalVisible = false;
+ }
+
+ doGenerateToken(): void {
+ if (!this.newTokenName || !this.newTokenName.trim()) {
+ return;
+ }
+ this.generateLoading = true;
+ this.isGenerateModalVisible = false;
+ this.authSvc.generateToken(this.newTokenName.trim(),
this.selectedExpire).subscribe(
+ message => {
+ if (message.code === 0) {
+ this.generatedToken = message.data?.token || '';
+ this.isTokenModalVisible = true;
+ this.loadTokens();
+ } else {
+
this.notifySvc.warning(this.i18nSvc.fanyi('settings.token.generate-fail'),
message.msg);
+ }
+ this.generateLoading = false;
+ },
+ () => {
+ this.generateLoading = false;
+ }
+ );
+ }
+
+ handleTokenModalClose(): void {
+ this.isTokenModalVisible = false;
+ this.generatedToken = '';
+ }
+
+ confirmDelete(token: AuthToken): void {
+ this.modalSvc.confirm({
+ nzTitle: this.i18nSvc.fanyi('settings.token.delete-confirm'),
+ nzContent: this.i18nSvc.fanyi('settings.token.delete-confirm-content', {
name: token.name || token.tokenMask }),
+ nzOkDanger: true,
+ nzOkText: this.i18nSvc.fanyi('common.button.delete'),
+ nzOnOk: () => {
+ this.authSvc.deleteToken(token.id).subscribe(
+ message => {
+ if (message.code === 0) {
+
this.notifySvc.success(this.i18nSvc.fanyi('common.notify.delete-success'), '');
+ this.loadTokens();
+ } else {
+
this.notifySvc.error(this.i18nSvc.fanyi('common.notify.delete-fail'),
message.msg);
+ }
+ },
+ () => {
+
this.notifySvc.error(this.i18nSvc.fanyi('common.notify.delete-fail'), '');
+ }
+ );
+ }
+ });
+ }
+
+ isExpired(token: AuthToken): boolean {
+ if (!token.expireTime) {
+ return false;
+ }
+ return new Date(token.expireTime).getTime() < Date.now();
+ }
+
+ copyToken(): void {
+ navigator.clipboard
+ .writeText(this.generatedToken)
+ .then(() => {
+
this.notifySvc.success(this.i18nSvc.fanyi('common.notify.copy-success'), '');
+ })
+ .catch(() => {
+ const el = document.createElement('textarea');
+ el.value = this.generatedToken;
+ document.body.appendChild(el);
+ el.select();
+ const success = document.execCommand('copy');
+ document.body.removeChild(el);
+ if (success) {
+
this.notifySvc.success(this.i18nSvc.fanyi('common.notify.copy-success'), '');
+ } else {
+
this.notifySvc.warning(this.i18nSvc.fanyi('common.notify.copy-fail'), '');
+ }
+ });
+ }
+}
diff --git a/web-app/src/app/service/auth.service.ts
b/web-app/src/app/service/auth.service.ts
index fe8350ac3f..ccc8ab20ff 100644
--- a/web-app/src/app/service/auth.service.ts
+++ b/web-app/src/app/service/auth.service.ts
@@ -39,7 +39,22 @@ export class AuthService {
return this.http.post<Message<any>>(`${account_auth_refresh_uri}`, body);
}
- public generateToken(): Observable<Message<any>> {
- return this.http.post<Message<any>>(`${account_token}` + '/generate', {});
+ public generateToken(name?: string, expireSeconds?: number):
Observable<Message<any>> {
+ let params: any = {};
+ if (name) {
+ params.name = name;
+ }
+ if (expireSeconds != null) {
+ params.expireSeconds = expireSeconds;
+ }
+ return this.http.post<Message<any>>(`${account_token}/generate`, {}, {
params });
+ }
+
+ public listTokens(): Observable<Message<any>> {
+ return this.http.get<Message<any>>(`${account_token}`);
+ }
+
+ public deleteToken(id: number): Observable<Message<any>> {
+ return this.http.delete<Message<any>>(`${account_token}/${id}`);
}
}
diff --git a/web-app/src/assets/i18n/en-US.json
b/web-app/src/assets/i18n/en-US.json
index ef905fe312..a2407bf068 100644
--- a/web-app/src/assets/i18n/en-US.json
+++ b/web-app/src/assets/i18n/en-US.json
@@ -104,6 +104,7 @@
"alert.integration.source.huaweicloud-ces": "Huawei Cloud Eye",
"alert.integration.source.volcengine": "Volcengine Monitoring",
"alert.integration.token.desc": "Token you generated that can be used to
access the HertzBeat API.",
+ "alert.integration.token.manage": "Manage API Tokens",
"alert.integration.token.new": "Click to Generate Token",
"alert.integration.token.notice": "Token only be displayed once. Please keep
your token secure. Do not share it with others.",
"alert.integration.token.title": "Access Token",
@@ -623,6 +624,7 @@
"log.integration.source": "Integrated Log Source",
"log.integration.source.otlp": "OTLP Protocol",
"log.integration.token.desc": "The generated Token can be used to access the
HertzBeat log access API",
+ "log.integration.token.manage": "Manage API Tokens",
"log.integration.token.new": "Click to generate Token",
"log.integration.token.notice": "This content will only be displayed once.
Please keep your Token safe and do not disclose it to others.",
"log.integration.token.title": "Access Authentication Token",
@@ -1001,6 +1003,30 @@
"settings.system-config.theme.dark": "Dark Theme",
"settings.system-config.theme.default": "Default Theme",
"settings.system-config.timezone": "System Time Zone",
+ "settings.token": "API Token Management",
+ "settings.token.desc": "Manage long-lived API tokens for integration access.
Create or revoke tokens at any time.",
+ "settings.token.generate": "Generate Token",
+ "settings.token.generate-title": "Generate New API Token",
+ "settings.token.generate-fail": "Failed to generate token",
+ "settings.token.generated-title": "Token Generated",
+ "settings.token.name": "Token Name",
+ "settings.token.name-placeholder": "Enter token name",
+ "settings.token.value": "Token Value",
+ "settings.token.creator": "Creator",
+ "settings.token.create-time": "Created At",
+ "settings.token.notice": "This token will only be displayed once. Please
keep it secure and do not share it. You can revoke it by deleting it from this
page.",
+ "settings.token.delete-confirm": "Confirm Delete Token",
+ "settings.token.delete-confirm-content": "All integrations using this token
will lose access. Are you sure you want to delete \"{{name}}\"?",
+ "settings.token.empty": "No API tokens yet. Click the button above to
generate one.",
+ "settings.token.expire-time": "Expiration",
+ "settings.token.expire.never": "Never",
+ "settings.token.expire.7d": "7 Days",
+ "settings.token.expire.30d": "30 Days",
+ "settings.token.expire.90d": "90 Days",
+ "settings.token.expire.180d": "180 Days",
+ "settings.token.expire.365d": "365 Days",
+ "settings.token.expired": "Expired",
+ "settings.token.last-used": "Last Used",
"status.component": "Component",
"status.component.config-state": "Configuration State",
"status.component.config-state.tip": "When the status calculates mode is
manual, the configured component service status.",
diff --git a/web-app/src/assets/i18n/zh-CN.json
b/web-app/src/assets/i18n/zh-CN.json
index 5f6e4bcb7c..6f585e092f 100644
--- a/web-app/src/assets/i18n/zh-CN.json
+++ b/web-app/src/assets/i18n/zh-CN.json
@@ -104,6 +104,7 @@
"alert.integration.source.huaweicloud-ces": "华为云监控服务",
"alert.integration.source.volcengine": "火山引擎云监控",
"alert.integration.token.desc": "生成的 Token 可用于访问 HertzBeat API",
+ "alert.integration.token.manage": "管理 API Token",
"alert.integration.token.new": "点击生成 Token",
"alert.integration.token.notice": "此内容只会展示一次,请妥善保管您的 Token,不要泄露给他人",
"alert.integration.token.title": "访问认证 Token",
@@ -626,6 +627,7 @@
"log.integration.source": "集成日志源",
"log.integration.source.otlp": "OTLP 协议",
"log.integration.token.desc": "生成的 Token 可用于访问 HertzBeat 日志接入API",
+ "log.integration.token.manage": "管理 API Token",
"log.integration.token.new": "点击生成 Token",
"log.integration.token.notice": "此内容只会展示一次,请妥善保管您的 Token,不要泄露给他人",
"log.integration.token.title": "访问认证 Token",
@@ -1004,6 +1006,30 @@
"settings.system-config.theme.dark": "深色主题",
"settings.system-config.theme.default": "浅色主题",
"settings.system-config.timezone": "系统时区",
+ "settings.token": "API Token 管理",
+ "settings.token.desc": "管理用于 API 集成访问的长期 Token,可随时创建或撤销。",
+ "settings.token.generate": "生成 Token",
+ "settings.token.generate-title": "生成新的 API Token",
+ "settings.token.generate-fail": "生成 Token 失败",
+ "settings.token.generated-title": "Token 已生成",
+ "settings.token.name": "Token 名称",
+ "settings.token.name-placeholder": "请输入 Token 名称",
+ "settings.token.value": "Token 值",
+ "settings.token.creator": "创建者",
+ "settings.token.create-time": "创建时间",
+ "settings.token.notice": "此 Token 只会展示一次,请妥善保管,不要泄露给他人。如需撤销授权,可在此页面删除对应
Token。",
+ "settings.token.delete-confirm": "确认删除 Token",
+ "settings.token.delete-confirm-content": "删除后使用此 Token
的所有集成将无法访问,确认删除「{{name}}」?",
+ "settings.token.empty": "暂无 API Token,点击上方按钮生成",
+ "settings.token.expire-time": "过期时间",
+ "settings.token.expire.never": "永不过期",
+ "settings.token.expire.7d": "7 天",
+ "settings.token.expire.30d": "30 天",
+ "settings.token.expire.90d": "90 天",
+ "settings.token.expire.180d": "180 天",
+ "settings.token.expire.365d": "365 天",
+ "settings.token.expired": "已过期",
+ "settings.token.last-used": "最近使用时间",
"status.component": "服务组件",
"status.component.config-state": "配置状态",
"status.component.config-state.tip": "当状态统计方式为手动时,配置的组件服务状态。",
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]