From a29c6083116780d37b76e768ad55337f6fdd1082 Mon Sep 17 00:00:00 2001 From: John Niang Date: Sun, 24 Sep 2023 22:30:14 -0500 Subject: [PATCH] Support for personal access token mechanism (#4598) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #### What type of PR is this? /kind feature /kind api-change /area core #### What this PR does / why we need it: Support for personal access token mechanism. #### Which issue(s) this PR fixes: Fixes https://github.com/halo-dev/halo/issues/1309 #### Special notes for your reviewer: #### Does this PR introduce a user-facing change? ```release-note 提供个人访问令牌机制 ``` --- .../run/halo/app/core/extension/Role.java | 2 + .../app/security/PersonalAccessToken.java | 53 ++ .../app/config/WebServerSecurityConfig.java | 17 +- .../core/extension/endpoint/UserEndpoint.java | 108 ++- .../service/DefaultRoleBindingService.java | 5 +- .../extension/service/DefaultRoleService.java | 115 ++- .../core/extension/service/RoleService.java | 13 + .../run/halo/app/infra/SchemeInitializer.java | 6 +- .../DefaultSuperAdminInitializer.java | 2 - .../app/security/SuperAdminInitializer.java | 3 + ...esAndRolesGrantedAuthoritiesConverter.java | 45 + .../pat/DefaultPatJwkSupplier.java | 89 ++ .../pat/PatAuthenticationConverter.java | 31 - .../pat/PatAuthenticationManager.java | 99 +- .../authentication/pat/PatEndpoint.java | 100 +++ .../authentication/pat/PatJwkSupplier.java | 9 + .../pat/PatServerWebExchangeMatcher.java | 30 + .../pat/PersonalAccessToken.java | 39 - .../pat/UserScopedPatHandler.java | 20 + .../pat/impl/UserScopedPatHandlerImpl.java | 283 ++++++ .../authorization/AuthorityUtils.java | 41 + .../role-template-authenticated.yaml | 23 +- .../extension/endpoint/UserEndpointTest.java | 27 +- .../service/DefaultRoleServiceTest.java | 135 ++- .../security/authentication/pat/PatTest.java | 40 + .../authorization/AuthorityUtilsTest.java | 37 + .../api-client/src/.openapi-generator/FILES | 5 +- console/packages/api-client/src/api.ts | 3 +- ...-run-v1alpha1-personal-access-token-api.ts | 808 +++++++++++++++++ ...-run-v1alpha1-personal-access-token-api.ts | 846 ++++++++++++++++++ .../packages/api-client/src/models/index.ts | 2 +- .../api-client/src/models/pat-spec.ts | 81 ++ .../src/models/personal-access-token.ts | 6 +- .../api-client/src/models/user-permission.ts | 6 + console/src/constants/annotations.ts | 5 + console/src/locales/en.yaml | 41 + console/src/locales/zh-CN.yaml | 41 + console/src/locales/zh-TW.yaml | 41 + .../src/modules/system/roles/RoleDetail.vue | 15 +- .../roles/components/RoleEditingModal.vue | 18 +- .../system/roles/composables/use-role.ts | 29 +- .../system/users/PersonalAccessTokens.vue | 361 ++------ .../PersonalAccessTokenCreationModal.vue | 279 ++++++ .../PersonalAccessTokenListItem.vue | 149 +++ .../users/layouts/UserProfileLayout.vue | 33 +- console/src/stores/role.ts | 1 + console/src/utils/api-client.ts | 18 +- console/vite.config.ts | 6 +- docs/authentication/README.md | 131 ++- 49 files changed, 3743 insertions(+), 554 deletions(-) create mode 100644 api/src/main/java/run/halo/app/security/PersonalAccessToken.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/DefaultPatJwkSupplier.java delete mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/PatJwkSupplier.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java delete mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/PersonalAccessToken.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java create mode 100644 application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java create mode 100644 application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java create mode 100644 application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java create mode 100644 application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java create mode 100644 console/packages/api-client/src/api/api-console-security-halo-run-v1alpha1-personal-access-token-api.ts create mode 100644 console/packages/api-client/src/api/security-halo-run-v1alpha1-personal-access-token-api.ts create mode 100644 console/packages/api-client/src/models/pat-spec.ts create mode 100644 console/src/modules/system/users/components/PersonalAccessTokenCreationModal.vue create mode 100644 console/src/modules/system/users/components/PersonalAccessTokenListItem.vue diff --git a/api/src/main/java/run/halo/app/core/extension/Role.java b/api/src/main/java/run/halo/app/core/extension/Role.java index a754428654..ebda43f4d0 100644 --- a/api/src/main/java/run/halo/app/core/extension/Role.java +++ b/api/src/main/java/run/halo/app/core/extension/Role.java @@ -38,6 +38,8 @@ public class Role extends AbstractExtension { public static final String SYSTEM_RESERVED_LABELS = "rbac.authorization.halo.run/system-reserved"; + public static final String HIDDEN_LABEL_NAME = "halo.run/hidden"; + public static final String TEMPLATE_LABEL_NAME = "halo.run/role-template"; public static final String UI_PERMISSIONS_AGGREGATED_ANNO = "rbac.authorization.halo.run/ui-permissions-aggregated"; diff --git a/api/src/main/java/run/halo/app/security/PersonalAccessToken.java b/api/src/main/java/run/halo/app/security/PersonalAccessToken.java new file mode 100644 index 0000000000..333c225b1f --- /dev/null +++ b/api/src/main/java/run/halo/app/security/PersonalAccessToken.java @@ -0,0 +1,53 @@ +package run.halo.app.security; + +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.Instant; +import java.util.List; +import lombok.Data; +import lombok.EqualsAndHashCode; +import lombok.ToString; +import run.halo.app.extension.AbstractExtension; +import run.halo.app.extension.GVK; + +@Data +@ToString(callSuper = true) +@EqualsAndHashCode(callSuper = true) +@GVK(group = "security.halo.run", version = "v1alpha1", kind = PersonalAccessToken.KIND, + plural = "personalaccesstokens", singular = "personalaccesstoken") +public class PersonalAccessToken extends AbstractExtension { + + public static final String KIND = "PersonalAccessToken"; + + private Spec spec = new Spec(); + + @Data + @Schema(name = "PatSpec") + public static class Spec { + + @Schema(requiredMode = REQUIRED) + private String name; + + private String description; + + private Instant expiresAt; + + private List roles; + + private List scopes; + + @Schema(requiredMode = REQUIRED) + private String username; + + private boolean revoked; + + private Instant revokesAt; + + private Instant lastUsed; + + @Schema(requiredMode = REQUIRED) + private String tokenId; + + } +} diff --git a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java index 7406e3763b..9b150b0746 100644 --- a/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java +++ b/application/src/main/java/run/halo/app/config/WebServerSecurityConfig.java @@ -1,6 +1,7 @@ package run.halo.app.config; import static org.springframework.security.config.Customizer.withDefaults; +import static org.springframework.security.web.server.authentication.ServerWebExchangeDelegatingReactiveAuthenticationManagerResolver.builder; import static org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers.pathMatchers; import java.util.Set; @@ -26,6 +27,7 @@ import org.springframework.web.reactive.function.server.ServerResponse; import run.halo.app.core.extension.service.RoleService; import run.halo.app.core.extension.service.UserService; +import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.AnonymousUserConst; import run.halo.app.infra.properties.HaloProperties; import run.halo.app.plugin.extensionpoint.ExtensionGetter; @@ -37,6 +39,9 @@ import run.halo.app.security.authentication.login.PublicKeyRouteBuilder; import run.halo.app.security.authentication.login.RsaKeyScheduledGenerator; import run.halo.app.security.authentication.login.impl.RsaKeyService; +import run.halo.app.security.authentication.pat.PatAuthenticationManager; +import run.halo.app.security.authentication.pat.PatJwkSupplier; +import run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher; import run.halo.app.security.authorization.RequestInfoAuthorizationManager; /** @@ -55,7 +60,9 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http, RoleService roleService, ObjectProvider securityConfigurers, ServerSecurityContextRepository securityContextRepository, - ExtensionGetter extensionGetter) { + ExtensionGetter extensionGetter, + ReactiveExtensionClient client, + PatJwkSupplier patJwkSupplier) { http.securityMatcher(pathMatchers("/api/**", "/apis/**", "/oauth2/**", "/login/**", "/logout", "/actuator/**")) @@ -68,6 +75,14 @@ SecurityWebFilterChain apiFilterChain(ServerHttpSecurity http, }) .securityContextRepository(securityContextRepository) .httpBasic(withDefaults()) + .oauth2ResourceServer(oauth2 -> { + var authManagerResolver = builder().add( + new PatServerWebExchangeMatcher(), + new PatAuthenticationManager(client, patJwkSupplier)) + // TODO Add other authentication mangers here. e.g.: JwtAuthentiationManager. + .build(); + oauth2.authenticationManagerResolver(authManagerResolver); + }) .exceptionHandling( spec -> spec.authenticationEntryPoint(new DefaultServerAuthenticationEntryPoint())); diff --git a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java index 58463a269b..20faeb57c0 100644 --- a/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java +++ b/application/src/main/java/run/halo/app/core/extension/endpoint/UserEndpoint.java @@ -13,6 +13,7 @@ import static run.halo.app.extension.ListResult.generateGenericClass; import static run.halo.app.extension.router.QueryParamBuildUtil.buildParametersFromType; import static run.halo.app.extension.router.selector.SelectorUtil.labelAndFieldSelectorToPredicate; +import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; import com.fasterxml.jackson.core.type.TypeReference; import com.google.common.io.Files; @@ -21,6 +22,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.Duration; import java.util.ArrayList; +import java.util.Collections; import java.util.Comparator; import java.util.HashMap; import java.util.HashSet; @@ -31,8 +33,9 @@ import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Function; import java.util.function.Predicate; +import java.util.stream.Collectors; +import lombok.Data; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springdoc.core.fn.builders.requestbody.Builder; @@ -483,45 +486,86 @@ record GrantRequest(Set roles) { @NonNull private Mono getUserPermission(ServerRequest request) { - String name = request.pathVariable("name"); - return ReactiveSecurityContextHolder.getContext() - .map(ctx -> SELF_USER.equals(name) ? ctx.getAuthentication().getName() : name) - .flatMapMany(userService::listRoles) - .reduce(new LinkedHashSet(), (list, role) -> { - list.add(role); - return list; - }) - .flatMap(roles -> uiPermissions(roles) - .collectList() - .map(uiPermissions -> new UserPermission(roles, Set.copyOf(uiPermissions))) - .defaultIfEmpty(new UserPermission(roles, Set.of())) - ) - .flatMap(result -> ServerResponse.ok() - .contentType(MediaType.APPLICATION_JSON) - .bodyValue(result) - ); + var name = request.pathVariable("name"); + Mono userPermission; + if (SELF_USER.equals(name)) { + userPermission = ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + var roleNames = authoritiesToRoles(auth.getAuthorities()); + var up = new UserPermission(); + var roles = roleService.list(roleNames) + .collect(Collectors.toSet()) + .doOnNext(up::setRoles) + .then(); + var permissions = roleService.listPermissions(roleNames) + .distinct() + .collectList() + .doOnNext(up::setPermissions) + .doOnNext(perms -> { + var uiPermissions = uiPermissions(new HashSet<>(perms)); + up.setUiPermissions(uiPermissions); + }) + .then(); + return roles.and(permissions).thenReturn(up); + }); + } else { + // get roles from username + userPermission = userService.listRoles(name) + .collect(Collectors.toSet()) + .flatMap(roles -> { + var up = new UserPermission(); + var setRoles = Mono.fromRunnable(() -> up.setRoles(roles)).then(); + var roleNames = roles.stream() + .map(role -> role.getMetadata().getName()) + .collect(Collectors.toSet()); + var setPermissions = roleService.listPermissions(roleNames) + .distinct() + .collectList() + .doOnNext(up::setPermissions) + .doOnNext(perms -> { + var uiPermissions = uiPermissions(new HashSet<>(perms)); + up.setUiPermissions(uiPermissions); + }) + .then(); + return setRoles.and(setPermissions).thenReturn(up); + }); + } + + return ServerResponse.ok().body(userPermission, UserPermission.class); } - private Flux uiPermissions(Set roles) { - return Flux.fromIterable(roles) - .map(role -> role.getMetadata().getName()) - .collectList() - .flatMapMany(roleNames -> roleService.listDependenciesFlux(Set.copyOf(roleNames))) - .map(role -> { - Map annotations = MetadataUtil.nullSafeAnnotations(role); - String uiPermissionStr = annotations.get(Role.UI_PERMISSIONS_ANNO); - if (StringUtils.isBlank(uiPermissionStr)) { - return new HashSet(); + private Set uiPermissions(Set roles) { + if (CollectionUtils.isEmpty(roles)) { + return Collections.emptySet(); + } + return roles.stream() + .>map(role -> { + var annotations = role.getMetadata().getAnnotations(); + if (annotations == null) { + return Set.of(); } - return JsonUtils.jsonToObject(uiPermissionStr, + var uiPermissionsJson = annotations.get(Role.UI_PERMISSIONS_ANNO); + if (StringUtils.isBlank(uiPermissionsJson)) { + return Set.of(); + } + return JsonUtils.jsonToObject(uiPermissionsJson, new TypeReference>() { }); }) - .flatMapIterable(Function.identity()); + .flatMap(Set::stream) + .collect(Collectors.toSet()); } - record UserPermission(@Schema(requiredMode = REQUIRED) Set roles, - @Schema(requiredMode = REQUIRED) Set uiPermissions) { + @Data + public static class UserPermission { + @Schema(requiredMode = REQUIRED) + private Set roles; + @Schema(requiredMode = REQUIRED) + private List permissions; + @Schema(requiredMode = REQUIRED) + private Set uiPermissions; + } public class ListRequest extends IListRequest.QueryListRequest { diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java index d73c9ad09b..9152f77319 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java +++ b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleBindingService.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.web.authentication.AnonymousAuthenticationFilter; +import run.halo.app.security.authorization.AuthorityUtils; /** *

Obtain the authorities from the authenticated authentication and construct it as a RoleBinding @@ -21,8 +22,8 @@ */ @Slf4j public class DefaultRoleBindingService implements RoleBindingService { - private static final String SCOPE_AUTHORITY_PREFIX = "SCOPE_"; - private static final String ROLE_AUTHORITY_PREFIX = "ROLE_"; + private static final String SCOPE_AUTHORITY_PREFIX = AuthorityUtils.SCOPE_PREFIX; + private static final String ROLE_AUTHORITY_PREFIX = AuthorityUtils.ROLE_PREFIX; @Override public Set listBoundRoleNames(Collection authorities) { diff --git a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java index 12460b1bba..567685b4bb 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java +++ b/application/src/main/java/run/halo/app/core/extension/service/DefaultRoleService.java @@ -1,20 +1,23 @@ package run.halo.app.core.extension.service; -import static run.halo.app.extension.MetadataUtil.nullSafeLabels; +import static run.halo.app.extension.Comparators.compareCreationTimestamp; +import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole; import com.fasterxml.jackson.core.type.TypeReference; +import java.util.Collection; import java.util.Collections; -import java.util.Comparator; import java.util.HashSet; import java.util.List; import java.util.Set; import java.util.function.Predicate; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; -import org.apache.commons.lang3.ObjectUtils; import org.apache.commons.lang3.StringUtils; import org.springframework.lang.NonNull; import org.springframework.stereotype.Service; +import org.springframework.util.CollectionUtils; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; import run.halo.app.core.extension.RoleBinding.RoleRef; @@ -22,6 +25,7 @@ import run.halo.app.extension.MetadataUtil; import run.halo.app.extension.ReactiveExtensionClient; import run.halo.app.infra.utils.JsonUtils; +import run.halo.app.security.SuperAdminInitializer; /** * @author guqing @@ -45,38 +49,108 @@ public Flux listRoleRefs(Subject subject) { .map(RoleBinding::getRoleRef); } + @Override + public Mono contains(Collection source, Collection candidates) { + if (source.contains(SuperAdminInitializer.SUPER_ROLE_NAME)) { + return Mono.just(true); + } + return listDependencies(new HashSet<>(source), shouldFilterHidden(true)) + .map(role -> role.getMetadata().getName()) + .collect(Collectors.toSet()) + .map(roleNames -> roleNames.containsAll(candidates)); + } + + @Override + public Flux listPermissions(Set names) { + if (containsSuperRole(names)) { + // search all permissions + return extensionClient.list(Role.class, + shouldFilterHidden(true), + compareCreationTimestamp(true)) + .filter(DefaultRoleService::isRoleTemplate); + } + return listDependencies(names, shouldFilterHidden(true)) + .filter(DefaultRoleService::isRoleTemplate); + } + @Override public Flux listDependenciesFlux(Set names) { - if (names == null) { + return listDependencies(names, shouldFilterHidden(false)); + } + + private Flux listRoles(Set names, Predicate additionalPredicate) { + if (CollectionUtils.isEmpty(names)) { return Flux.empty(); } - Set visited = new HashSet<>(); - return Flux.fromIterable(names) - .flatMap(name -> extensionClient.fetch(Role.class, name)) + + Predicate predicate = role -> names.contains(role.getMetadata().getName()); + if (additionalPredicate != null) { + predicate = predicate.and(additionalPredicate); + } + return extensionClient.list(Role.class, predicate, compareCreationTimestamp(true)); + } + + private static Predicate shouldFilterHidden(boolean filterHidden) { + if (!filterHidden) { + return r -> true; + } + return role -> { + var labels = role.getMetadata().getLabels(); + if (labels == null) { + return true; + } + var hiddenValue = labels.get(Role.HIDDEN_LABEL_NAME); + return !Boolean.parseBoolean(hiddenValue); + }; + } + + private static boolean isRoleTemplate(Role role) { + var labels = role.getMetadata().getLabels(); + if (labels == null) { + return false; + } + return Boolean.parseBoolean(labels.get(Role.TEMPLATE_LABEL_NAME)); + } + + private Flux listDependencies(Set names, Predicate additionalPredicate) { + var visited = new HashSet(); + return listRoles(names, additionalPredicate) .expand(role -> { var name = role.getMetadata().getName(); if (visited.contains(name)) { return Flux.empty(); } + log.debug("Expand role: {}", role.getMetadata().getName()); visited.add(name); var annotations = MetadataUtil.nullSafeAnnotations(role); var dependenciesJson = annotations.get(Role.ROLE_DEPENDENCIES_ANNO); var dependencies = stringToList(dependenciesJson); + return Flux.fromIterable(dependencies) - .filter(dependency -> !visited.contains(dependency)) - .flatMap(dependencyName -> extensionClient.fetch(Role.class, dependencyName)); + .filter(dep -> !visited.contains(dep)) + .collect(Collectors.toSet()) + .flatMapMany(deps -> listRoles(deps, additionalPredicate)); }) - .flatMap(role -> Flux.just(role) - .mergeWith(listAggregatedRoles(role.getMetadata().getName())) - ); + .concatWith(Flux.defer(() -> listAggregatedRoles(visited, additionalPredicate))); } - Flux listAggregatedRoles(String roleName) { - return extensionClient.list(Role.class, - role -> Boolean.parseBoolean(nullSafeLabels(role) - .get(Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName) - ), - Comparator.comparing(item -> item.getMetadata().getCreationTimestamp())); + private Flux listAggregatedRoles(Set roleNames, + Predicate additionalPredicate) { + var aggregatedLabelNames = roleNames.stream() + .map(roleName -> Role.ROLE_AGGREGATE_LABEL_PREFIX + roleName) + .collect(Collectors.toSet()); + Predicate predicate = role -> { + var labels = role.getMetadata().getLabels(); + if (labels == null) { + return false; + } + return aggregatedLabelNames.stream() + .anyMatch(aggregatedLabel -> Boolean.parseBoolean(labels.get(aggregatedLabel))); + }; + if (additionalPredicate != null) { + predicate = predicate.and(additionalPredicate); + } + return extensionClient.list(Role.class, predicate, compareCreationTimestamp(true)); } Predicate getRoleBindingPredicate(Subject targetSubject) { @@ -101,7 +175,10 @@ private static boolean matchSubject(Subject targetSubject, Subject subject) { @Override public Flux list(Set roleNames) { - return Flux.fromIterable(ObjectUtils.defaultIfNull(roleNames, Set.of())) + if (CollectionUtils.isEmpty(roleNames)) { + return Flux.empty(); + } + return Flux.fromIterable(roleNames) .flatMap(roleName -> extensionClient.fetch(Role.class, roleName)); } diff --git a/application/src/main/java/run/halo/app/core/extension/service/RoleService.java b/application/src/main/java/run/halo/app/core/extension/service/RoleService.java index 54f6773896..e52917dcd4 100644 --- a/application/src/main/java/run/halo/app/core/extension/service/RoleService.java +++ b/application/src/main/java/run/halo/app/core/extension/service/RoleService.java @@ -1,7 +1,9 @@ package run.halo.app.core.extension.service; +import java.util.Collection; import java.util.Set; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding.RoleRef; import run.halo.app.core.extension.RoleBinding.Subject; @@ -14,6 +16,17 @@ public interface RoleService { Flux listRoleRefs(Subject subject); + Mono contains(Collection source, Collection candidates); + + /** + * This method lists all role templates as permissions recursively according to given role + * name set. + * + * @param names is role name set. + * @return an array of permissions. + */ + Flux listPermissions(Set names); + Flux listDependenciesFlux(Set names); Flux list(Set roleNames); diff --git a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java index f95a89e6db..252418e5c8 100644 --- a/application/src/main/java/run/halo/app/infra/SchemeInitializer.java +++ b/application/src/main/java/run/halo/app/infra/SchemeInitializer.java @@ -36,7 +36,7 @@ import run.halo.app.plugin.extensionpoint.ExtensionDefinition; import run.halo.app.plugin.extensionpoint.ExtensionPointDefinition; import run.halo.app.search.extension.SearchEngine; -import run.halo.app.security.authentication.pat.PersonalAccessToken; +import run.halo.app.security.PersonalAccessToken; @Component public class SchemeInitializer implements ApplicationListener { @@ -54,7 +54,6 @@ public SchemeInitializer(SchemeManager schemeManager, @Override public void onApplicationEvent(@NonNull ApplicationStartedEvent event) { schemeManager.register(Role.class); - schemeManager.register(PersonalAccessToken.class); // plugin.halo.run schemeManager.register(Plugin.class); @@ -90,6 +89,9 @@ public void onApplicationEvent(@NonNull ApplicationStartedEvent event) { schemeManager.register(AuthProvider.class); schemeManager.register(UserConnection.class); + // security.halo.run + schemeManager.register(PersonalAccessToken.class); + // migration.halo.run schemeManager.register(Backup.class); diff --git a/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java b/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java index ec98893d29..01ed1ca99c 100644 --- a/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java +++ b/application/src/main/java/run/halo/app/security/DefaultSuperAdminInitializer.java @@ -21,8 +21,6 @@ @RequiredArgsConstructor public class DefaultSuperAdminInitializer implements SuperAdminInitializer { - private static final String SUPER_ROLE_NAME = "super-role"; - private final ReactiveExtensionClient client; private final PasswordEncoder passwordEncoder; diff --git a/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java b/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java index 935ed02310..802d87ff17 100644 --- a/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java +++ b/application/src/main/java/run/halo/app/security/SuperAdminInitializer.java @@ -3,6 +3,7 @@ import lombok.Builder; import lombok.Data; import reactor.core.publisher.Mono; +import run.halo.app.security.authorization.AuthorityUtils; /** * Super admin initializer. @@ -12,6 +13,8 @@ */ public interface SuperAdminInitializer { + String SUPER_ROLE_NAME = AuthorityUtils.SUPER_ROLE_NAME; + /** * Initialize super admin. * diff --git a/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java new file mode 100644 index 0000000000..7a32808456 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/jwt/JwtScopesAndRolesGrantedAuthoritiesConverter.java @@ -0,0 +1,45 @@ +package run.halo.app.security.authentication.jwt; + +import java.util.ArrayList; +import java.util.Collection; +import org.springframework.core.convert.converter.Converter; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.util.CollectionUtils; +import reactor.core.publisher.Flux; +import run.halo.app.security.authorization.AuthorityUtils; + +/** + * GrantedAuthorities converter for SCOPE_ and ROLE_ prefixes. + * + * @author johnniang + */ +public class JwtScopesAndRolesGrantedAuthoritiesConverter + implements Converter> { + + private final Converter> delegate; + + public JwtScopesAndRolesGrantedAuthoritiesConverter() { + delegate = new JwtGrantedAuthoritiesConverter(); + } + + @Override + public Flux convert(Jwt jwt) { + var grantedAuthorities = new ArrayList(); + var delegateAuthorities = delegate.convert(jwt); + if (delegateAuthorities != null) { + grantedAuthorities.addAll(delegateAuthorities); + } + var roles = jwt.getClaimAsStringList("roles"); + if (!CollectionUtils.isEmpty(roles)) { + roles.stream() + .map(role -> AuthorityUtils.ROLE_PREFIX + role) + .map(SimpleGrantedAuthority::new) + .forEach(grantedAuthorities::add); + } + return Flux.fromIterable(grantedAuthorities); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/DefaultPatJwkSupplier.java b/application/src/main/java/run/halo/app/security/authentication/pat/DefaultPatJwkSupplier.java new file mode 100644 index 0000000000..8183174c6b --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/DefaultPatJwkSupplier.java @@ -0,0 +1,89 @@ +package run.halo.app.security.authentication.pat; + +import static com.nimbusds.jose.jwk.KeyOperation.SIGN; +import static com.nimbusds.jose.jwk.KeyOperation.VERIFY; +import static java.nio.file.StandardOpenOption.TRUNCATE_EXISTING; + +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWK; +import com.nimbusds.jose.jwk.KeyUse; +import com.nimbusds.jose.jwk.RSAKey; +import com.nimbusds.jose.jwk.gen.RSAKeyGenerator; +import java.io.IOException; +import java.nio.file.Files; +import java.security.KeyFactory; +import java.security.KeyPair; +import java.security.NoSuchAlgorithmException; +import java.security.interfaces.RSAPublicKey; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.security.spec.X509EncodedKeySpec; +import java.util.Set; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import run.halo.app.infra.properties.HaloProperties; + +@Slf4j +@Component +public class DefaultPatJwkSupplier implements PatJwkSupplier { + + private final RSAKey rsaKey; + + public DefaultPatJwkSupplier(HaloProperties haloProperties) throws JOSEException { + var keyPair = getRsaKeyPairOrCreate(haloProperties); + this.rsaKey = new RSAKey.Builder((RSAPublicKey) keyPair.getPublic()) + .privateKey(keyPair.getPrivate()) + .keyUse(KeyUse.SIGNATURE) + .keyOperations(Set.of(SIGN, VERIFY)) + .keyIDFromThumbprint() + .algorithm(JWSAlgorithm.RS256) + .build(); + } + + private KeyPair getRsaKeyPairOrCreate(HaloProperties haloProperties) { + var keysRoot = haloProperties.getWorkDir().resolve("keys"); + var privKeyPath = keysRoot.resolve("pat_id_rsa"); + var pubKeyPath = keysRoot.resolve("pat_id_rsa.pub"); + try { + if (Files.exists(privKeyPath) && Files.exists(pubKeyPath)) { + log.debug("Skip initializing RSA Keys for PAT due to existence."); + + var keyFactory = KeyFactory.getInstance("RSA"); + + var privKeyBytes = Files.readAllBytes(privKeyPath); + var privKeySpec = new PKCS8EncodedKeySpec(privKeyBytes); + var privKey = keyFactory.generatePrivate(privKeySpec); + + var pubKeyBytes = Files.readAllBytes(pubKeyPath); + var pubKeySpec = new X509EncodedKeySpec(pubKeyBytes); + var pubKey = keyFactory.generatePublic(pubKeySpec); + + return new KeyPair(pubKey, privKey); + } + + if (Files.notExists(keysRoot)) { + Files.createDirectories(keysRoot); + } + Files.createFile(privKeyPath); + Files.createFile(pubKeyPath); + + log.info("Generating RSA keys for PAT."); + var rsaKey = new RSAKeyGenerator(4096).generate(); + var pubKey = rsaKey.toRSAPublicKey(); + var privKey = rsaKey.toRSAPrivateKey(); + Files.write(privKeyPath, privKey.getEncoded(), TRUNCATE_EXISTING); + Files.write(pubKeyPath, pubKey.getEncoded(), TRUNCATE_EXISTING); + log.info("Wrote RSA keys for PAT into {} and {}", privKeyPath, pubKeyPath); + return new KeyPair(pubKey, privKey); + } catch (JOSEException | IOException + | InvalidKeySpecException | NoSuchAlgorithmException e) { + throw new RuntimeException(e); + } + } + + @Override + public JWK getJwk() { + return rsaKey; + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java deleted file mode 100644 index 205a23ade1..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java +++ /dev/null @@ -1,31 +0,0 @@ -package run.halo.app.security.authentication.pat; - -import org.springframework.security.core.Authentication; -import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; -import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; -import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; -import org.springframework.web.server.ServerWebExchange; -import reactor.core.publisher.Mono; - -public class PatAuthenticationConverter implements ServerAuthenticationConverter { - - private final ServerBearerTokenAuthenticationConverter bearerTokenConverter; - - public PatAuthenticationConverter() { - bearerTokenConverter = new ServerBearerTokenAuthenticationConverter(); - } - - @Override - public Mono convert(ServerWebExchange exchange) { - return bearerTokenConverter.convert(exchange) - .filter(token -> token instanceof BearerTokenAuthenticationToken) - .cast(BearerTokenAuthenticationToken.class) - .filter(this::isPersonalAccessToken) - .cast(Authentication.class); - } - - private boolean isPersonalAccessToken(BearerTokenAuthenticationToken bearerToken) { - String token = bearerToken.getToken(); - return token.startsWith("pat_"); - } -} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java index 001fae9cd8..02f8cee197 100644 --- a/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationManager.java @@ -1,14 +1,109 @@ package run.halo.app.security.authentication.pat; +import static org.apache.commons.lang3.StringUtils.removeStart; +import static org.springframework.security.oauth2.jwt.NimbusReactiveJwtDecoder.withJwkSource; +import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX; + +import com.nimbusds.jwt.JWTClaimNames; +import java.time.Clock; +import java.util.Objects; +import org.springframework.security.authentication.DisabledException; import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.server.resource.InvalidBearerTokenException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.security.oauth2.server.resource.authentication.JwtReactiveAuthenticationManager; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; +import reactor.core.publisher.Flux; import reactor.core.publisher.Mono; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.security.PersonalAccessToken; +import run.halo.app.security.authentication.jwt.JwtScopesAndRolesGrantedAuthoritiesConverter; public class PatAuthenticationManager implements ReactiveAuthenticationManager { + private final ReactiveAuthenticationManager delegate; + + private final ReactiveExtensionClient client; + + private Clock clock; + + public PatAuthenticationManager(ReactiveExtensionClient client, PatJwkSupplier jwkSupplier) { + this.client = client; + this.delegate = getDelegate(jwkSupplier); + this.clock = Clock.systemDefaultZone(); + } + + private ReactiveAuthenticationManager getDelegate(PatJwkSupplier jwkSupplier) { + var jwtDecoder = withJwkSource(signedJWT -> Flux.just(jwkSupplier.getJwk())) + .build(); + var jwtAuthManager = new JwtReactiveAuthenticationManager(jwtDecoder); + var jwtAuthConverter = new ReactiveJwtAuthenticationConverter(); + jwtAuthConverter.setJwtGrantedAuthoritiesConverter( + new JwtScopesAndRolesGrantedAuthoritiesConverter()); + jwtAuthManager.setJwtAuthenticationConverter(jwtAuthConverter); + return jwtAuthManager; + } + + public void setClock(Clock clock) { + this.clock = clock; + } + @Override public Mono authenticate(Authentication authentication) { - // TODO Implement personal access token authentication. - return null; + return delegate.authenticate(clearPrefix(authentication)) + .transformDeferred(auth -> auth.filter(a -> a instanceof JwtAuthenticationToken) + .cast(JwtAuthenticationToken.class) + .flatMap(jwtAuthToken -> checkAvailability(jwtAuthToken).thenReturn(jwtAuthToken))); + } + + private Authentication clearPrefix(Authentication authentication) { + if (authentication instanceof BearerTokenAuthenticationToken bearerToken) { + var newToken = removeStart(bearerToken.getToken(), PAT_TOKEN_PREFIX); + return new BearerTokenAuthenticationToken(newToken); + } + return authentication; + } + + private Mono checkAvailability(JwtAuthenticationToken jwtAuthToken) { + var jwt = jwtAuthToken.getToken(); + var patName = jwt.getClaimAsString("pat_name"); + var jwtId = jwt.getClaimAsString(JWTClaimNames.JWT_ID); + if (patName == null || jwtId == null) { + // Skip if the JWT token is not a PAT. + return Mono.empty(); + } + return client.fetch(PersonalAccessToken.class, patName) + .switchIfEmpty( + Mono.error(() -> new DisabledException("Personal access token has been deleted."))) + .flatMap(pat -> patChecks(pat, jwtId) + .then(updateLastUsed(pat)) + .then() + ); + } + + private Mono updateLastUsed(PersonalAccessToken pat) { + return Mono.defer(() -> { + pat.getSpec().setLastUsed(clock.instant()); + return client.update(pat); + }); + } + + private Mono patChecks(PersonalAccessToken pat, String tokenId) { + if (ExtensionUtil.isDeleted(pat)) { + return Mono.error( + new InvalidBearerTokenException("Personal access token is being deleted.")); + } + var spec = pat.getSpec(); + if (!Objects.equals(spec.getTokenId(), tokenId)) { + return Mono.error(new InvalidBearerTokenException( + "Token ID does not match the token ID of personal access token.")); + } + if (spec.isRevoked()) { + return Mono.error(new InvalidBearerTokenException("Token has been revoked.")); + } + return Mono.empty(); } } diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java new file mode 100644 index 0000000000..efc49c35d7 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatEndpoint.java @@ -0,0 +1,100 @@ +package run.halo.app.security.authentication.pat; + +import static org.springdoc.core.fn.builders.apiresponse.Builder.responseBuilder; +import static org.springdoc.core.fn.builders.parameter.Builder.parameterBuilder; +import static org.springdoc.core.fn.builders.requestbody.Builder.requestBodyBuilder; +import static org.springdoc.webflux.core.fn.SpringdocRouteBuilder.route; +import static org.springframework.web.reactive.function.server.RequestPredicates.path; + +import io.swagger.v3.oas.annotations.enums.ParameterIn; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.server.RouterFunction; +import org.springframework.web.reactive.function.server.ServerResponse; +import run.halo.app.core.extension.endpoint.CustomEndpoint; +import run.halo.app.extension.GroupVersion; +import run.halo.app.security.PersonalAccessToken; + +@Component +public class PatEndpoint implements CustomEndpoint { + + private final UserScopedPatHandler patHandler; + + public PatEndpoint(UserScopedPatHandler patHandler) { + this.patHandler = patHandler; + } + + @Override + public RouterFunction endpoint() { + var tag = groupVersion().toString() + "/" + PersonalAccessToken.KIND; + return route().nest(path("/users/-/personalaccesstokens"), + () -> route() + .POST(patHandler::create, + builder -> builder + .tag(tag) + .operationId("GeneratePat") + .description("Generate a PAT.") + .requestBody(requestBodyBuilder() + .required(true) + .implementation(PersonalAccessToken.class)) + .response(responseBuilder().implementation(PersonalAccessToken.class)) + ) + .GET(patHandler::list, + builder -> builder + .tag(tag) + .operationId("ObtainPats") + .description("Obtain PAT list.") + .response(responseBuilder() + .implementationArray(PersonalAccessToken.class) + ) + ) + .GET("/{name}", patHandler::get, + builder -> builder + .tag(tag) + .operationId("ObtainPat") + .description("Obtain a PAT.") + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .required(true) + .name("name"))) + .PUT("/{name}/actions/revocation", + patHandler::revoke, + builder -> builder.tag(tag) + .operationId("RevokePat") + .description("Revoke a PAT") + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .required(true) + .name("name")) + ) + .PUT("/{name}/actions/restoration", + patHandler::restore, + builder -> builder.tag(tag) + .operationId("RestorePat") + .description("Restore a PAT.") + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .required(true) + .name("name") + ) + ) + .DELETE("/{name}", + patHandler::delete, + builder -> builder.tag(tag) + .operationId("DeletePat") + .description("Delete a PAT") + .parameter(parameterBuilder() + .in(ParameterIn.PATH) + .required(true) + .name("name") + )) + .build(), + builder -> builder.description("User-scoped PersonalAccessToken endpoint")) + .build(); + } + + @Override + public GroupVersion groupVersion() { + return GroupVersion.parseAPIVersion("api.security.halo.run/v1alpha1"); + } + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatJwkSupplier.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatJwkSupplier.java new file mode 100644 index 0000000000..b023329f2d --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatJwkSupplier.java @@ -0,0 +1,9 @@ +package run.halo.app.security.authentication.pat; + +import com.nimbusds.jose.jwk.JWK; + +public interface PatJwkSupplier { + + JWK getJwk(); + +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java b/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java new file mode 100644 index 0000000000..4d8a7e1772 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/PatServerWebExchangeMatcher.java @@ -0,0 +1,30 @@ +package run.halo.app.security.authentication.pat; + +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; +import org.springframework.security.oauth2.server.resource.web.server.authentication.ServerBearerTokenAuthenticationConverter; +import org.springframework.security.web.server.authentication.ServerAuthenticationConverter; +import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatcher; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +public class PatServerWebExchangeMatcher implements ServerWebExchangeMatcher { + + public static final String PAT_TOKEN_PREFIX = "pat_"; + + private final ServerAuthenticationConverter authConverter = + new ServerBearerTokenAuthenticationConverter(); + + @Override + public Mono matches(ServerWebExchange exchange) { + return authConverter.convert(exchange) + .filter(a -> a instanceof BearerTokenAuthenticationToken) + .cast(BearerTokenAuthenticationToken.class) + .map(BearerTokenAuthenticationToken::getToken) + .filter(tokenString -> StringUtils.startsWith(tokenString, PAT_TOKEN_PREFIX)) + .flatMap(t -> MatchResult.match()) + .onErrorResume(AuthenticationException.class, t -> MatchResult.notMatch()) + .switchIfEmpty(Mono.defer(MatchResult::notMatch)); + } +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/PersonalAccessToken.java b/application/src/main/java/run/halo/app/security/authentication/pat/PersonalAccessToken.java deleted file mode 100644 index 50d58027a2..0000000000 --- a/application/src/main/java/run/halo/app/security/authentication/pat/PersonalAccessToken.java +++ /dev/null @@ -1,39 +0,0 @@ -package run.halo.app.security.authentication.pat; - -import java.time.Instant; -import lombok.Data; -import lombok.EqualsAndHashCode; -import lombok.ToString; -import run.halo.app.extension.AbstractExtension; -import run.halo.app.extension.GVK; - -@GVK(group = "", - version = "v1alpha1", - kind = "PersonalAccessToken", - singular = "personalaccesstoken", - plural = "personalaccesstokens") -@Data -@ToString(callSuper = true) -@EqualsAndHashCode(callSuper = true) -public class PersonalAccessToken extends AbstractExtension { - - private PersonalAccessTokenSpec spec; - - @Data - public static class PersonalAccessTokenSpec { - - private String userName; - - private String displayName; - - private Boolean revoked; - - private Instant expiresAt; - - private String scopes; - - private String tokenDigest; - - } - -} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java new file mode 100644 index 0000000000..f552a52573 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandler.java @@ -0,0 +1,20 @@ +package run.halo.app.security.authentication.pat; + +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import reactor.core.publisher.Mono; + +public interface UserScopedPatHandler { + + Mono create(ServerRequest request); + + Mono list(ServerRequest request); + + Mono get(ServerRequest request); + + Mono revoke(ServerRequest request); + + Mono delete(ServerRequest request); + + Mono restore(ServerRequest request); +} diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java b/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java new file mode 100644 index 0000000000..600cf1e296 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authentication/pat/impl/UserScopedPatHandlerImpl.java @@ -0,0 +1,283 @@ +package run.halo.app.security.authentication.pat.impl; + +import static org.apache.commons.lang3.StringUtils.startsWith; +import static run.halo.app.extension.Comparators.compareCreationTimestamp; +import static run.halo.app.security.authentication.pat.PatServerWebExchangeMatcher.PAT_TOKEN_PREFIX; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import java.time.Clock; +import java.util.Collection; +import java.util.HashMap; +import java.util.List; +import java.util.Objects; +import java.util.function.Predicate; +import java.util.stream.Collectors; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.ReactiveSecurityContextHolder; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; +import org.springframework.stereotype.Service; +import org.springframework.util.AlternativeJdkIdGenerator; +import org.springframework.util.CollectionUtils; +import org.springframework.util.IdGenerator; +import org.springframework.web.reactive.function.server.ServerRequest; +import org.springframework.web.reactive.function.server.ServerResponse; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.extension.service.RoleService; +import run.halo.app.extension.ExtensionUtil; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.extension.exception.ExtensionNotFoundException; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.exception.AccessDeniedException; +import run.halo.app.infra.exception.NotFoundException; +import run.halo.app.security.PersonalAccessToken; +import run.halo.app.security.authentication.pat.PatJwkSupplier; +import run.halo.app.security.authentication.pat.UserScopedPatHandler; +import run.halo.app.security.authorization.AuthorityUtils; + +@Service +public class UserScopedPatHandlerImpl implements UserScopedPatHandler { + + private static final String ROLE_PREFIX = AuthorityUtils.ROLE_PREFIX; + + private static final String ACCESS_TOKEN_ANNO_NAME = "security.halo.run/access-token"; + + private static final NotFoundException PAT_NOT_FOUND_EX = + new NotFoundException("The personal access token was not found or deleted."); + + private final ReactiveExtensionClient client; + + private final JwtEncoder patEncoder; + + private final ExternalUrlSupplier externalUrl; + + private final RoleService roleService; + + private final IdGenerator idGenerator; + + private final String keyId; + + private Clock clock; + + public UserScopedPatHandlerImpl(ReactiveExtensionClient client, + PatJwkSupplier jwkSupplier, + ExternalUrlSupplier externalUrl, + RoleService roleService) { + this.client = client; + this.externalUrl = externalUrl; + this.roleService = roleService; + + var patJwk = jwkSupplier.getJwk(); + var jwkSet = new ImmutableJWKSet<>(new JWKSet(patJwk)); + this.patEncoder = new NimbusJwtEncoder(jwkSet); + this.keyId = patJwk.getKeyID(); + this.idGenerator = new AlternativeJdkIdGenerator(); + this.clock = Clock.systemDefaultZone(); + } + + public void setClock(Clock clock) { + this.clock = clock; + } + + private static Mono mustBeRealUser(Mono authentication) { + return authentication.filter(auth -> auth.getPrincipal() instanceof UserDetails) + // Non-username-password authentication could not access the API at any time. + .switchIfEmpty( + Mono.defer(() -> Mono.error(new AccessDeniedException()))); + } + + @Override + public Mono create(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .transform(UserScopedPatHandlerImpl::mustBeRealUser) + .flatMap(auth -> request.bodyToMono(PersonalAccessToken.class) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Missing request body."))) + .flatMap(patRequest -> { + var patSpec = patRequest.getSpec(); + var roles = patSpec.getRoles(); + var rolesCheck = hasSufficientRoles(auth.getAuthorities(), roles) + .filter(has -> has) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Insufficient roles."))) + .then(); + + var expiresCheck = Mono.fromRunnable(() -> { + var expiresAt = patSpec.getExpiresAt(); + var now = clock.instant(); + if (expiresAt != null && (now.isAfter(expiresAt))) { + throw new ServerWebInputException("Invalid expiresAt."); + } + }).then(); + + var createPat = Mono.defer(() -> { + var pat = new PersonalAccessToken(); + var spec = pat.getSpec(); + spec.setUsername(auth.getName()); + spec.setName(patSpec.getName()); + spec.setDescription(patSpec.getDescription()); + spec.setRoles(patSpec.getRoles()); + spec.setScopes(patSpec.getScopes()); + spec.setExpiresAt(patSpec.getExpiresAt()); + var tokenId = idGenerator.generateId().toString(); + spec.setTokenId(tokenId); + var metadata = new Metadata(); + metadata.setGenerateName("pat-" + auth.getName() + "-"); + pat.setMetadata(metadata); + return client.create(pat) + .doOnNext(createdPat -> { + var claimsBuilder = JwtClaimsSet.builder() + .issuer(externalUrl.getURL(request.exchange().getRequest()) + .toString()) + .id(tokenId) + .subject(auth.getName()) + .issuedAt(clock.instant()) + .claim("roles", roles) + .claim("pat_name", createdPat.getMetadata().getName()); + var expiresAt = createdPat.getSpec().getExpiresAt(); + if (expiresAt != null) { + claimsBuilder.expiresAt(expiresAt); + } + var headerBuilder = JwsHeader.with(SignatureAlgorithm.RS256) + .keyId(this.keyId); + var jwt = patEncoder.encode(JwtEncoderParameters.from( + headerBuilder.build(), + claimsBuilder.build())); + // TODO Create PAT for the token. + var annotations = + createdPat.getMetadata().getAnnotations(); + if (annotations == null) { + annotations = new HashMap<>(); + createdPat.getMetadata().setAnnotations(annotations); + } + annotations.put(ACCESS_TOKEN_ANNO_NAME, + PAT_TOKEN_PREFIX + jwt.getTokenValue()); + }); + }); + return rolesCheck.and(expiresCheck).then(createPat) + .flatMap(createdPat -> ServerResponse.ok().bodyValue(createdPat)); + })); + } + + @Override + public Mono list(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + Predicate predicate = + pat -> Objects.equals(auth.getName(), pat.getSpec().getUsername()); + var pats = client.list(PersonalAccessToken.class, predicate, + compareCreationTimestamp(false)); + return ServerResponse.ok().body(pats, PersonalAccessToken.class); + }); + } + + @Override + public Mono get(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + var name = request.pathVariable("name"); + var pat = getPat(name, auth.getName()); + return ServerResponse.ok().body(pat, PersonalAccessToken.class); + }); + } + + @Override + public Mono revoke(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + var name = request.pathVariable("name"); + var revokedPat = getPat(name, auth.getName()) + .filter(pat -> !pat.getSpec().isRevoked()) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The token has been revoked before."))) + .doOnNext(pat -> { + var spec = pat.getSpec(); + spec.setRevoked(true); + spec.setRevokesAt(clock.instant()); + }) + .flatMap(client::update); + return ServerResponse.ok().body(revokedPat, PersonalAccessToken.class); + }); + } + + @Override + public Mono delete(ServerRequest request) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .flatMap(auth -> { + var name = request.pathVariable("name"); + var deletedPat = getPat(name, auth.getName()) + .flatMap(client::delete); + return ServerResponse.ok().body(deletedPat, PersonalAccessToken.class); + }); + } + + @Override + public Mono restore(ServerRequest request) { + var restoredPat = ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + .transform(UserScopedPatHandlerImpl::mustBeRealUser) + .flatMap(auth -> { + var name = request.pathVariable("name"); + return getPat(name, auth.getName()); + }) + .filter(pat -> pat.getSpec().isRevoked()) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException( + "The token has not been revoked before."))) + .doOnNext(pat -> { + var spec = pat.getSpec(); + spec.setRevoked(false); + spec.setRevokesAt(null); + }) + .flatMap(client::update); + return ServerResponse.ok().body(restoredPat, PersonalAccessToken.class); + } + + private Mono hasSufficientRoles( + Collection grantedAuthorities, List requestRoles) { + if (CollectionUtils.isEmpty(requestRoles)) { + return Mono.just(true); + } + var grantedRoles = AuthorityUtils.authoritiesToRoles(grantedAuthorities); + return roleService.contains(grantedRoles, requestRoles); + } + + private static boolean containsIllegalRoles( + Collection grantedAuthorities, + List roles) { + if (CollectionUtils.isEmpty(roles)) { + return false; + } + var roleSet = roles.stream() + .map(role -> ROLE_PREFIX + role) + .collect(Collectors.toSet()); + var grantedRoleSet = grantedAuthorities.stream() + .map(GrantedAuthority::getAuthority) + .filter(authority -> startsWith(authority, ROLE_PREFIX)) + .collect(Collectors.toSet()); + return !grantedRoleSet.containsAll(roleSet); + } + + private Mono getPat(String name, String username) { + return client.get(PersonalAccessToken.class, name) + .filter(pat -> Objects.equals(pat.getSpec().getUsername(), username) + && !ExtensionUtil.isDeleted(pat)) + .onErrorMap(ExtensionNotFoundException.class, t -> PAT_NOT_FOUND_EX) + .switchIfEmpty(Mono.error(() -> PAT_NOT_FOUND_EX)); + } +} diff --git a/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java new file mode 100644 index 0000000000..b00fa310d0 --- /dev/null +++ b/application/src/main/java/run/halo/app/security/authorization/AuthorityUtils.java @@ -0,0 +1,41 @@ +package run.halo.app.security.authorization; + +import java.util.Collection; +import java.util.Set; +import java.util.stream.Collectors; +import org.apache.commons.lang3.StringUtils; +import org.springframework.security.core.GrantedAuthority; + +/** + * Utility methods for manipulating GrantedAuthority collection. + * + * @author johnniang + */ +public enum AuthorityUtils { + ; + + public static final String SCOPE_PREFIX = "SCOPE_"; + + public static final String ROLE_PREFIX = "ROLE_"; + + public static final String SUPER_ROLE_NAME = "super-role"; + + /** + * Converts an array of GrantedAuthority objects to a role set. + * + * @return a Set of the Strings obtained from each call to + * GrantedAuthority.getAuthority() and filtered by prefix "ROLE_". + */ + public static Set authoritiesToRoles( + Collection authorities) { + return authorities.stream() + .map(GrantedAuthority::getAuthority) + .filter(authority -> StringUtils.startsWith(authority, ROLE_PREFIX)) + .map(authority -> StringUtils.removeStart(authority, ROLE_PREFIX)) + .collect(Collectors.toSet()); + } + + public static boolean containsSuperRole(Collection roles) { + return roles.contains(SUPER_ROLE_NAME); + } +} diff --git a/application/src/main/resources/extensions/role-template-authenticated.yaml b/application/src/main/resources/extensions/role-template-authenticated.yaml index 95df303e32..64b8f21372 100644 --- a/application/src/main/resources/extensions/role-template-authenticated.yaml +++ b/application/src/main/resources/extensions/role-template-authenticated.yaml @@ -12,7 +12,8 @@ metadata: "role-template-own-permissions", "role-template-change-own-password", "role-template-stats", - "role-template-annotation-setting" + "role-template-annotation-setting", + "role-template-manage-own-pat" ] rules: - apiGroups: [ "" ] @@ -93,4 +94,22 @@ metadata: rules: - apiGroups: [ "" ] resources: [ "annotationsettings" ] - verbs: [ "get", "list" ] \ No newline at end of file + verbs: [ "get", "list" ] + +--- +apiVersion: v1alpha1 +kind: "Role" +metadata: + name: role-template-manage-own-pat + labels: + halo.run/role-template: "true" + halo.run/hidden: "true" +rules: + - apiGroups: [ "api.console.security.halo.run" ] + resources: [ "users/personalaccesstokens" ] + resourceNames: [ "-" ] + verbs: [ "*" ] + - apiGroups: [ "api.console.security.halo.run" ] + resources: [ "users/personalaccesstokens/actions" ] + resourceNames: [ "-" ] + verbs: [ "update" ] \ No newline at end of file diff --git a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java index 2d79aa9567..179aba87eb 100644 --- a/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java +++ b/application/src/test/java/run/halo/app/core/extension/endpoint/UserEndpointTest.java @@ -1,5 +1,6 @@ package run.halo.app.core.extension.endpoint; +import static org.junit.jupiter.api.Assertions.assertEquals; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; import static org.mockito.ArgumentMatchers.anySet; @@ -436,6 +437,7 @@ void shouldGetPermission() { "rules": [] } """, Role.class); + when(roleService.listPermissions(eq(Set.of("test-A")))).thenReturn(Flux.just(roleA)); when(userService.listRoles(eq("fake-user"))).thenReturn( Flux.fromIterable(List.of(roleA))); when(roleService.listDependenciesFlux(anySet())).thenReturn(Flux.just(roleA)); @@ -444,25 +446,12 @@ void shouldGetPermission() { .exchange() .expectStatus() .isOk() - .expectBody() - .json(""" - { "roles": [{ - "rules": [], - "apiVersion": "v1alpha1", - "kind": "Role", - "metadata": { - "name": "test-A", - "annotations": { - "rbac.authorization.halo.run/ui-permissions": - "[\\"permission-A\\"]" - } - } - }], - "uiPermissions": [ - "permission-A" - ] - } - """); + .expectBody(UserEndpoint.UserPermission.class) + .value(userPermission -> { + assertEquals(Set.of(roleA), userPermission.getRoles()); + assertEquals(List.of(roleA), userPermission.getPermissions()); + assertEquals(Set.of("permission-A"), userPermission.getUiPermissions()); + }); verify(userService, times(1)).listRoles(eq("fake-user")); } diff --git a/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java b/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java index 8db68ba588..33d0997859 100644 --- a/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java +++ b/application/src/test/java/run/halo/app/core/extension/service/DefaultRoleServiceTest.java @@ -2,8 +2,9 @@ import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.same; import static org.mockito.Mockito.eq; -import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -22,7 +23,6 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; import reactor.test.StepVerifier; import run.halo.app.core.extension.Role; import run.halo.app.core.extension.RoleBinding; @@ -49,21 +49,21 @@ class ListDependenciesTest { @Test void listDependencies() { // prepare test data - Role role1 = createRole("role1", "role2"); - Role role2 = createRole("role2", "role3"); - Role role3 = createRole("role3"); + var role1 = createRole("role1", "role2"); + var role2 = createRole("role2", "role3"); + var role3 = createRole("role3"); - Set roleNames = Set.of("role1"); + var roleNames = Set.of("role1"); - // setup mocks - when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1)); - when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2)); - when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3)); - when(extensionClient.list(eq(Role.class), any(), any())) + + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.just(role1)) + .thenReturn(Flux.just(role2)) + .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test - Flux result = roleService.listDependenciesFlux(roleNames); + var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) @@ -73,27 +73,27 @@ void listDependencies() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(3)).fetch(eq(Role.class), anyString()); + verify(extensionClient, times(4)).list(same(Role.class), any(), any()); } @Test void listDependenciesWithCycle() { // prepare test data - Role role1 = createRole("role1", "role2"); - Role role2 = createRole("role2", "role3"); - Role role3 = createRole("role3", "role1"); + var role1 = createRole("role1", "role2"); + var role2 = createRole("role2", "role3"); + var role3 = createRole("role3", "role1"); - Set roleNames = Set.of("role1"); + var roleNames = Set.of("role1"); // setup mocks - when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1)); - when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2)); - when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3)); - when(extensionClient.list(eq(Role.class), any(), any())) + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.just(role1)) + .thenReturn(Flux.just(role2)) + .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test - Flux result = roleService.listDependenciesFlux(roleNames); + var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) @@ -103,7 +103,7 @@ void listDependenciesWithCycle() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(3)).fetch(eq(Role.class), anyString()); + verify(extensionClient, times(4)).list(same(Role.class), any(), any()); } @Test @@ -111,23 +111,23 @@ void listDependenciesWithMiddleCycle() { // prepare test data // role1 -> role2 -> role3 -> role4 // \<-----| - Role role1 = createRole("role1", "role2"); - Role role2 = createRole("role2", "role3"); - Role role3 = createRole("role3", "role2", "role4"); - Role role4 = createRole("role4"); + var role1 = createRole("role1", "role2"); + var role2 = createRole("role2", "role3"); + var role3 = createRole("role3", "role2", "role4"); + var role4 = createRole("role4"); - Set roleNames = Set.of("role1"); + var roleNames = Set.of("role1"); - // setup mocks - when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1)); - when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2)); - when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3)); - when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4)); - when(extensionClient.list(eq(Role.class), any(), any())) - .thenReturn(Flux.empty()); + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.just(role1)) + .thenReturn(Flux.just(role2)) + .thenReturn(Flux.just(role3)) + .thenReturn(Flux.just(role4)) + .thenReturn(Flux.empty()) + ; // call the method under test - Flux result = roleService.listDependenciesFlux(roleNames); + var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) @@ -138,7 +138,7 @@ void listDependenciesWithMiddleCycle() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(4)).fetch(eq(Role.class), anyString()); + verify(extensionClient, times(5)).list(same(Role.class), any(), any()); } @Test @@ -153,16 +153,14 @@ void listDependenciesWithCycleAndSequence() { Set roleNames = Set.of("role1"); - // setup mocks - when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1)); - when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2)); - when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3)); - when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4)); - when(extensionClient.list(eq(Role.class), any(), any())) + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.just(role1)) + .thenReturn(Flux.just(role4, role2)) + .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test - Flux result = roleService.listDependenciesFlux(roleNames); + var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) @@ -173,7 +171,7 @@ void listDependenciesWithCycleAndSequence() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(4)).fetch(eq(Role.class), anyString()); + verify(extensionClient, times(4)).list(same(Role.class), any(), any()); } @Test @@ -188,16 +186,13 @@ void listDependenciesAfterCycle() { Set roleNames = Set.of("role2"); - // setup mocks - lenient().when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1)); - when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2)); - when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.just(role3)); - lenient().when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4)); - when(extensionClient.list(eq(Role.class), any(), any())) + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.just(role2)) + .thenReturn(Flux.just(role3)) .thenReturn(Flux.empty()); // call the method under test - Flux result = roleService.listDependenciesFlux(roleNames); + var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) @@ -206,12 +201,17 @@ void listDependenciesAfterCycle() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(2)).fetch(eq(Role.class), anyString()); + verify(extensionClient, times(3)).list(same(Role.class), any(), any()); } @Test void listDependenciesWithNullParam() { - Flux result = roleService.listDependenciesFlux(null); + var result = roleService.listDependenciesFlux(null); + + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.empty()) + .thenReturn(Flux.empty()); + // verify the result StepVerifier.create(result) .verifyComplete(); @@ -221,26 +221,25 @@ void listDependenciesWithNullParam() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(0)).fetch(eq(Role.class), anyString()); + verify(extensionClient, never()).fetch(eq(Role.class), anyString()); } @Test void listDependenciesAndSomeOneNotFound() { - Role role1 = createRole("role1", "role2"); - Role role2 = createRole("role2", "role3", "role4"); - Role role4 = createRole("role4"); + var role1 = createRole("role1", "role2"); + var role2 = createRole("role2", "role3", "role4"); + var role4 = createRole("role4"); - Set roleNames = Set.of("role1"); + var roleNames = Set.of("role1"); - // setup mocks - when(extensionClient.fetch(Role.class, "role1")).thenReturn(Mono.just(role1)); - when(extensionClient.fetch(Role.class, "role2")).thenReturn(Mono.just(role2)); - when(extensionClient.fetch(Role.class, "role3")).thenReturn(Mono.empty()); - when(extensionClient.fetch(Role.class, "role4")).thenReturn(Mono.just(role4)); - when(extensionClient.list(eq(Role.class), any(), any())) - .thenReturn(Flux.empty()); + when(extensionClient.list(same(Role.class), any(), any())) + .thenReturn(Flux.just(role1)) + .thenReturn(Flux.just(role2)) + .thenReturn(Flux.just(role4)) + .thenReturn(Flux.empty()) + ; - Flux result = roleService.listDependenciesFlux(roleNames); + var result = roleService.listDependenciesFlux(roleNames); // verify the result StepVerifier.create(result) .expectNext(role1) @@ -249,7 +248,7 @@ void listDependenciesAndSomeOneNotFound() { .verifyComplete(); // verify the mock invocations - verify(extensionClient, times(4)).fetch(eq(Role.class), anyString()); + verify(extensionClient, times(4)).list(same(Role.class), any(), any()); } @Test diff --git a/application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java b/application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java new file mode 100644 index 0000000000..c1e0f91b48 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authentication/pat/PatTest.java @@ -0,0 +1,40 @@ +package run.halo.app.security.authentication.pat; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.reactive.AutoConfigureWebTestClient; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.web.reactive.server.WebTestClient; +import run.halo.app.security.PersonalAccessToken; + +@SpringBootTest +@AutoConfigureWebTestClient +class PatTest { + + @Autowired + WebTestClient webClient; + + @Test + @WithMockUser(username = "faker", password = "${noop}password", roles = "super-role") + void generatePat() { + var requestPat = new PersonalAccessToken(); + var spec = requestPat.getSpec(); + spec.setRoles(List.of("super-role")); + spec.setName("Fake PAT"); + webClient.post() + .uri("/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens") + .bodyValue(requestPat) + .exchange() + .expectStatus().isOk() + .expectBody(PersonalAccessToken.class) + .value(pat -> { + var annotations = pat.getMetadata().getAnnotations(); + assertTrue(annotations.containsKey("security.halo.run/access-token")); + }); + } + +} diff --git a/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java new file mode 100644 index 0000000000..3fa86a6259 --- /dev/null +++ b/application/src/test/java/run/halo/app/security/authorization/AuthorityUtilsTest.java @@ -0,0 +1,37 @@ +package run.halo.app.security.authorization; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static run.halo.app.security.authorization.AuthorityUtils.authoritiesToRoles; +import static run.halo.app.security.authorization.AuthorityUtils.containsSuperRole; + +import java.util.List; +import java.util.Set; +import org.junit.jupiter.api.Test; +import org.springframework.security.core.authority.SimpleGrantedAuthority; + +class AuthorityUtilsTest { + + @Test + void authoritiesToRolesTest() { + var authorities = List.of( + new SimpleGrantedAuthority("ROLE_admin"), + new SimpleGrantedAuthority("ROLE_owner"), + new SimpleGrantedAuthority("ROLE_manager"), + new SimpleGrantedAuthority("faker"), + new SimpleGrantedAuthority("SCOPE_system:read") + ); + + var roles = authoritiesToRoles(authorities); + + assertEquals(Set.of("admin", "owner", "manager"), roles); + } + + @Test + void containsSuperRoleTest() { + assertTrue(containsSuperRole(Set.of("super-role"))); + assertTrue(containsSuperRole(Set.of("super-role", "admin"))); + assertFalse(containsSuperRole(Set.of("admin"))); + } +} \ No newline at end of file diff --git a/console/packages/api-client/src/.openapi-generator/FILES b/console/packages/api-client/src/.openapi-generator/FILES index c0989d7452..54693000ea 100644 --- a/console/packages/api-client/src/.openapi-generator/FILES +++ b/console/packages/api-client/src/.openapi-generator/FILES @@ -14,6 +14,7 @@ api/api-console-halo-run-v1alpha1-system-api.ts api/api-console-halo-run-v1alpha1-theme-api.ts api/api-console-halo-run-v1alpha1-user-api.ts api/api-console-migration-halo-run-v1alpha1-migration-api.ts +api/api-console-security-halo-run-v1alpha1-personal-access-token-api.ts api/api-content-halo-run-v1alpha1-category-api.ts api/api-content-halo-run-v1alpha1-post-api.ts api/api-content-halo-run-v1alpha1-single-page-api.ts @@ -42,6 +43,7 @@ api/plugin-halo-run-v1alpha1-extension-point-definition-api.ts api/plugin-halo-run-v1alpha1-plugin-api.ts api/plugin-halo-run-v1alpha1-reverse-proxy-api.ts api/plugin-halo-run-v1alpha1-search-engine-api.ts +api/security-halo-run-v1alpha1-personal-access-token-api.ts api/storage-halo-run-v1alpha1-attachment-api.ts api/storage-halo-run-v1alpha1-group-api.ts api/storage-halo-run-v1alpha1-policy-api.ts @@ -52,7 +54,6 @@ api/v1alpha1-cache-api.ts api/v1alpha1-config-map-api.ts api/v1alpha1-menu-api.ts api/v1alpha1-menu-item-api.ts -api/v1alpha1-personal-access-token-api.ts api/v1alpha1-role-api.ts api/v1alpha1-role-binding-api.ts api/v1alpha1-secret-api.ts @@ -157,8 +158,8 @@ models/menu.ts models/metadata.ts models/navigation-post-vo.ts models/owner-info.ts +models/pat-spec.ts models/personal-access-token-list.ts -models/personal-access-token-spec.ts models/personal-access-token.ts models/plugin-author.ts models/plugin-list.ts diff --git a/console/packages/api-client/src/api.ts b/console/packages/api-client/src/api.ts index e7f9b9a08c..23a8c25752 100644 --- a/console/packages/api-client/src/api.ts +++ b/console/packages/api-client/src/api.ts @@ -25,6 +25,7 @@ export * from "./api/api-console-halo-run-v1alpha1-system-api"; export * from "./api/api-console-halo-run-v1alpha1-theme-api"; export * from "./api/api-console-halo-run-v1alpha1-user-api"; export * from "./api/api-console-migration-halo-run-v1alpha1-migration-api"; +export * from "./api/api-console-security-halo-run-v1alpha1-personal-access-token-api"; export * from "./api/api-content-halo-run-v1alpha1-category-api"; export * from "./api/api-content-halo-run-v1alpha1-post-api"; export * from "./api/api-content-halo-run-v1alpha1-single-page-api"; @@ -53,6 +54,7 @@ export * from "./api/plugin-halo-run-v1alpha1-extension-point-definition-api"; export * from "./api/plugin-halo-run-v1alpha1-plugin-api"; export * from "./api/plugin-halo-run-v1alpha1-reverse-proxy-api"; export * from "./api/plugin-halo-run-v1alpha1-search-engine-api"; +export * from "./api/security-halo-run-v1alpha1-personal-access-token-api"; export * from "./api/storage-halo-run-v1alpha1-attachment-api"; export * from "./api/storage-halo-run-v1alpha1-group-api"; export * from "./api/storage-halo-run-v1alpha1-policy-api"; @@ -63,7 +65,6 @@ export * from "./api/v1alpha1-cache-api"; export * from "./api/v1alpha1-config-map-api"; export * from "./api/v1alpha1-menu-api"; export * from "./api/v1alpha1-menu-item-api"; -export * from "./api/v1alpha1-personal-access-token-api"; export * from "./api/v1alpha1-role-api"; export * from "./api/v1alpha1-role-binding-api"; export * from "./api/v1alpha1-secret-api"; diff --git a/console/packages/api-client/src/api/api-console-security-halo-run-v1alpha1-personal-access-token-api.ts b/console/packages/api-client/src/api/api-console-security-halo-run-v1alpha1-personal-access-token-api.ts new file mode 100644 index 0000000000..2bb9e7e0bf --- /dev/null +++ b/console/packages/api-client/src/api/api-console-security-halo-run-v1alpha1-personal-access-token-api.ts @@ -0,0 +1,808 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { Configuration } from "../configuration"; +import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios"; +import globalAxios from "axios"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +// @ts-ignore +import { PersonalAccessToken } from "../models"; +/** + * ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi - axios parameter creator + * @export + */ +export const ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiAxiosParamCreator = + function (configuration?: Configuration) { + return { + /** + * Delete a PAT + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deletePat: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("deletePat", "name", name); + const localVarPath = + `/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens/{name}`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Generate a PAT. + * @param {PersonalAccessToken} personalAccessToken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + generatePat: async ( + personalAccessToken: PersonalAccessToken, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'personalAccessToken' is not null or undefined + assertParamExists( + "generatePat", + "personalAccessToken", + personalAccessToken + ); + const localVarPath = `/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + personalAccessToken, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Obtain a PAT. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + obtainPat: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("obtainPat", "name", name); + const localVarPath = + `/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens/{name}`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Obtain PAT list. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + obtainPats: async ( + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Restore a PAT. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restorePat: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("restorePat", "name", name); + const localVarPath = + `/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens/{name}/actions/restoration`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Revoke a PAT + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + revokePat: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists("revokePat", "name", name); + const localVarPath = + `/apis/api.security.halo.run/v1alpha1/users/-/personalaccesstokens/{name}/actions/revocation`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; + }; + +/** + * ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi - functional programming interface + * @export + */ +export const ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp = + function (configuration?: Configuration) { + const localVarAxiosParamCreator = + ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiAxiosParamCreator( + configuration + ); + return { + /** + * Delete a PAT + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deletePat( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.deletePat( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Generate a PAT. + * @param {PersonalAccessToken} personalAccessToken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async generatePat( + personalAccessToken: PersonalAccessToken, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.generatePat( + personalAccessToken, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Obtain a PAT. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async obtainPat( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.obtainPat( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Obtain PAT list. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async obtainPats( + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise> + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.obtainPats( + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Restore a PAT. + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async restorePat( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.restorePat( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Revoke a PAT + * @param {string} name + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async revokePat( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = await localVarAxiosParamCreator.revokePat( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; + }; + +/** + * ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi - factory interface + * @export + */ +export const ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFactory = + function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance + ) { + const localVarFp = + ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp(configuration); + return { + /** + * Delete a PAT + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deletePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePatRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .deletePat(requestParameters.name, options) + .then((request) => request(axios, basePath)); + }, + /** + * Generate a PAT. + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + generatePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePatRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .generatePat(requestParameters.personalAccessToken, options) + .then((request) => request(axios, basePath)); + }, + /** + * Obtain a PAT. + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + obtainPat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPatRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .obtainPat(requestParameters.name, options) + .then((request) => request(axios, basePath)); + }, + /** + * Obtain PAT list. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + obtainPats( + options?: AxiosRequestConfig + ): AxiosPromise> { + return localVarFp + .obtainPats(options) + .then((request) => request(axios, basePath)); + }, + /** + * Restore a PAT. + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + restorePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePatRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .restorePat(requestParameters.name, options) + .then((request) => request(axios, basePath)); + }, + /** + * Revoke a PAT + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + revokePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePatRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .revokePat(requestParameters.name, options) + .then((request) => request(axios, basePath)); + }, + }; + }; + +/** + * Request parameters for deletePat operation in ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePatRequest + */ +export interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePatRequest { + /** + * + * @type {string} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePat + */ + readonly name: string; +} + +/** + * Request parameters for generatePat operation in ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePatRequest + */ +export interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePatRequest { + /** + * + * @type {PersonalAccessToken} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePat + */ + readonly personalAccessToken: PersonalAccessToken; +} + +/** + * Request parameters for obtainPat operation in ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPatRequest + */ +export interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPatRequest { + /** + * + * @type {string} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPat + */ + readonly name: string; +} + +/** + * Request parameters for restorePat operation in ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePatRequest + */ +export interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePatRequest { + /** + * + * @type {string} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePat + */ + readonly name: string; +} + +/** + * Request parameters for revokePat operation in ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePatRequest + */ +export interface ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePatRequest { + /** + * + * @type {string} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePat + */ + readonly name: string; +} + +/** + * ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi - object-oriented interface + * @export + * @class ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + * @extends {BaseAPI} + */ +export class ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi extends BaseAPI { + /** + * Delete a PAT + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public deletePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiDeletePatRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp( + this.configuration + ) + .deletePat(requestParameters.name, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Generate a PAT. + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public generatePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiGeneratePatRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp( + this.configuration + ) + .generatePat(requestParameters.personalAccessToken, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Obtain a PAT. + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public obtainPat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiObtainPatRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp( + this.configuration + ) + .obtainPat(requestParameters.name, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Obtain PAT list. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public obtainPats(options?: AxiosRequestConfig) { + return ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp( + this.configuration + ) + .obtainPats(options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Restore a PAT. + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public restorePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRestorePatRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp( + this.configuration + ) + .restorePat(requestParameters.name, options) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Revoke a PAT + * @param {ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePatRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public revokePat( + requestParameters: ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiRevokePatRequest, + options?: AxiosRequestConfig + ) { + return ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApiFp( + this.configuration + ) + .revokePat(requestParameters.name, options) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/console/packages/api-client/src/api/security-halo-run-v1alpha1-personal-access-token-api.ts b/console/packages/api-client/src/api/security-halo-run-v1alpha1-personal-access-token-api.ts new file mode 100644 index 0000000000..9f1ef7c44d --- /dev/null +++ b/console/packages/api-client/src/api/security-halo-run-v1alpha1-personal-access-token-api.ts @@ -0,0 +1,846 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +import type { Configuration } from "../configuration"; +import type { AxiosPromise, AxiosInstance, AxiosRequestConfig } from "axios"; +import globalAxios from "axios"; +// Some imports not used depending on template conditions +// @ts-ignore +import { + DUMMY_BASE_URL, + assertParamExists, + setApiKeyToObject, + setBasicAuthToObject, + setBearerAuthToObject, + setOAuthToObject, + setSearchParams, + serializeDataIfNeeded, + toPathString, + createRequestFunction, +} from "../common"; +// @ts-ignore +import { + BASE_PATH, + COLLECTION_FORMATS, + RequestArgs, + BaseAPI, + RequiredError, +} from "../base"; +// @ts-ignore +import { PersonalAccessToken } from "../models"; +// @ts-ignore +import { PersonalAccessTokenList } from "../models"; +/** + * SecurityHaloRunV1alpha1PersonalAccessTokenApi - axios parameter creator + * @export + */ +export const SecurityHaloRunV1alpha1PersonalAccessTokenApiAxiosParamCreator = + function (configuration?: Configuration) { + return { + /** + * Create security.halo.run/v1alpha1/PersonalAccessToken + * @param {PersonalAccessToken} [personalAccessToken] Fresh personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createsecurityHaloRunV1alpha1PersonalAccessToken: async ( + personalAccessToken?: PersonalAccessToken, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/security.halo.run/v1alpha1/personalaccesstokens`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "POST", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + personalAccessToken, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Delete security.halo.run/v1alpha1/PersonalAccessToken + * @param {string} name Name of personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deletesecurityHaloRunV1alpha1PersonalAccessToken: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists( + "deletesecurityHaloRunV1alpha1PersonalAccessToken", + "name", + name + ); + const localVarPath = + `/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "DELETE", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Get security.halo.run/v1alpha1/PersonalAccessToken + * @param {string} name Name of personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getsecurityHaloRunV1alpha1PersonalAccessToken: async ( + name: string, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists( + "getsecurityHaloRunV1alpha1PersonalAccessToken", + "name", + name + ); + const localVarPath = + `/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * List security.halo.run/v1alpha1/PersonalAccessToken + * @param {Array} [fieldSelector] Field selector for filtering. + * @param {Array} [labelSelector] Label selector for filtering. + * @param {number} [page] The page number. Zero indicates no page. + * @param {number} [size] Size of one page. Zero indicates no limit. + * @param {Array} [sort] Sort property and direction of the list result. Support sorting based on attribute name path. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listsecurityHaloRunV1alpha1PersonalAccessToken: async ( + fieldSelector?: Array, + labelSelector?: Array, + page?: number, + size?: number, + sort?: Array, + options: AxiosRequestConfig = {} + ): Promise => { + const localVarPath = `/apis/security.halo.run/v1alpha1/personalaccesstokens`; + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "GET", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + if (fieldSelector) { + localVarQueryParameter["fieldSelector"] = fieldSelector; + } + + if (labelSelector) { + localVarQueryParameter["labelSelector"] = labelSelector; + } + + if (page !== undefined) { + localVarQueryParameter["page"] = page; + } + + if (size !== undefined) { + localVarQueryParameter["size"] = size; + } + + if (sort) { + localVarQueryParameter["sort"] = Array.from(sort); + } + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + /** + * Update security.halo.run/v1alpha1/PersonalAccessToken + * @param {string} name Name of personalaccesstoken + * @param {PersonalAccessToken} [personalAccessToken] Updated personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updatesecurityHaloRunV1alpha1PersonalAccessToken: async ( + name: string, + personalAccessToken?: PersonalAccessToken, + options: AxiosRequestConfig = {} + ): Promise => { + // verify required parameter 'name' is not null or undefined + assertParamExists( + "updatesecurityHaloRunV1alpha1PersonalAccessToken", + "name", + name + ); + const localVarPath = + `/apis/security.halo.run/v1alpha1/personalaccesstokens/{name}`.replace( + `{${"name"}}`, + encodeURIComponent(String(name)) + ); + // use dummy base URL string because the URL constructor only accepts absolute URLs. + const localVarUrlObj = new URL(localVarPath, DUMMY_BASE_URL); + let baseOptions; + if (configuration) { + baseOptions = configuration.baseOptions; + } + + const localVarRequestOptions = { + method: "PUT", + ...baseOptions, + ...options, + }; + const localVarHeaderParameter = {} as any; + const localVarQueryParameter = {} as any; + + // authentication BasicAuth required + // http basic authentication required + setBasicAuthToObject(localVarRequestOptions, configuration); + + // authentication BearerAuth required + // http bearer authentication required + await setBearerAuthToObject(localVarHeaderParameter, configuration); + + localVarHeaderParameter["Content-Type"] = "application/json"; + + setSearchParams(localVarUrlObj, localVarQueryParameter); + let headersFromBaseOptions = + baseOptions && baseOptions.headers ? baseOptions.headers : {}; + localVarRequestOptions.headers = { + ...localVarHeaderParameter, + ...headersFromBaseOptions, + ...options.headers, + }; + localVarRequestOptions.data = serializeDataIfNeeded( + personalAccessToken, + localVarRequestOptions, + configuration + ); + + return { + url: toPathString(localVarUrlObj), + options: localVarRequestOptions, + }; + }, + }; + }; + +/** + * SecurityHaloRunV1alpha1PersonalAccessTokenApi - functional programming interface + * @export + */ +export const SecurityHaloRunV1alpha1PersonalAccessTokenApiFp = function ( + configuration?: Configuration +) { + const localVarAxiosParamCreator = + SecurityHaloRunV1alpha1PersonalAccessTokenApiAxiosParamCreator( + configuration + ); + return { + /** + * Create security.halo.run/v1alpha1/PersonalAccessToken + * @param {PersonalAccessToken} [personalAccessToken] Fresh personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async createsecurityHaloRunV1alpha1PersonalAccessToken( + personalAccessToken?: PersonalAccessToken, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.createsecurityHaloRunV1alpha1PersonalAccessToken( + personalAccessToken, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Delete security.halo.run/v1alpha1/PersonalAccessToken + * @param {string} name Name of personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async deletesecurityHaloRunV1alpha1PersonalAccessToken( + name: string, + options?: AxiosRequestConfig + ): Promise< + (axios?: AxiosInstance, basePath?: string) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.deletesecurityHaloRunV1alpha1PersonalAccessToken( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Get security.halo.run/v1alpha1/PersonalAccessToken + * @param {string} name Name of personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async getsecurityHaloRunV1alpha1PersonalAccessToken( + name: string, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.getsecurityHaloRunV1alpha1PersonalAccessToken( + name, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * List security.halo.run/v1alpha1/PersonalAccessToken + * @param {Array} [fieldSelector] Field selector for filtering. + * @param {Array} [labelSelector] Label selector for filtering. + * @param {number} [page] The page number. Zero indicates no page. + * @param {number} [size] Size of one page. Zero indicates no limit. + * @param {Array} [sort] Sort property and direction of the list result. Support sorting based on attribute name path. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async listsecurityHaloRunV1alpha1PersonalAccessToken( + fieldSelector?: Array, + labelSelector?: Array, + page?: number, + size?: number, + sort?: Array, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.listsecurityHaloRunV1alpha1PersonalAccessToken( + fieldSelector, + labelSelector, + page, + size, + sort, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + /** + * Update security.halo.run/v1alpha1/PersonalAccessToken + * @param {string} name Name of personalaccesstoken + * @param {PersonalAccessToken} [personalAccessToken] Updated personalaccesstoken + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + async updatesecurityHaloRunV1alpha1PersonalAccessToken( + name: string, + personalAccessToken?: PersonalAccessToken, + options?: AxiosRequestConfig + ): Promise< + ( + axios?: AxiosInstance, + basePath?: string + ) => AxiosPromise + > { + const localVarAxiosArgs = + await localVarAxiosParamCreator.updatesecurityHaloRunV1alpha1PersonalAccessToken( + name, + personalAccessToken, + options + ); + return createRequestFunction( + localVarAxiosArgs, + globalAxios, + BASE_PATH, + configuration + ); + }, + }; +}; + +/** + * SecurityHaloRunV1alpha1PersonalAccessTokenApi - factory interface + * @export + */ +export const SecurityHaloRunV1alpha1PersonalAccessTokenApiFactory = function ( + configuration?: Configuration, + basePath?: string, + axios?: AxiosInstance +) { + const localVarFp = + SecurityHaloRunV1alpha1PersonalAccessTokenApiFp(configuration); + return { + /** + * Create security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + createsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessTokenRequest = {}, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .createsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.personalAccessToken, + options + ) + .then((request) => request(axios, basePath)); + }, + /** + * Delete security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + deletesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessTokenRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .deletesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.name, + options + ) + .then((request) => request(axios, basePath)); + }, + /** + * Get security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + getsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessTokenRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .getsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.name, + options + ) + .then((request) => request(axios, basePath)); + }, + /** + * List security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + listsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessTokenRequest = {}, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .listsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.fieldSelector, + requestParameters.labelSelector, + requestParameters.page, + requestParameters.size, + requestParameters.sort, + options + ) + .then((request) => request(axios, basePath)); + }, + /** + * Update security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + */ + updatesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessTokenRequest, + options?: AxiosRequestConfig + ): AxiosPromise { + return localVarFp + .updatesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.name, + requestParameters.personalAccessToken, + options + ) + .then((request) => request(axios, basePath)); + }, + }; +}; + +/** + * Request parameters for createsecurityHaloRunV1alpha1PersonalAccessToken operation in SecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessTokenRequest + */ +export interface SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessTokenRequest { + /** + * Fresh personalaccesstoken + * @type {PersonalAccessToken} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly personalAccessToken?: PersonalAccessToken; +} + +/** + * Request parameters for deletesecurityHaloRunV1alpha1PersonalAccessToken operation in SecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessTokenRequest + */ +export interface SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessTokenRequest { + /** + * Name of personalaccesstoken + * @type {string} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly name: string; +} + +/** + * Request parameters for getsecurityHaloRunV1alpha1PersonalAccessToken operation in SecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessTokenRequest + */ +export interface SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessTokenRequest { + /** + * Name of personalaccesstoken + * @type {string} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly name: string; +} + +/** + * Request parameters for listsecurityHaloRunV1alpha1PersonalAccessToken operation in SecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessTokenRequest + */ +export interface SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessTokenRequest { + /** + * Field selector for filtering. + * @type {Array} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly fieldSelector?: Array; + + /** + * Label selector for filtering. + * @type {Array} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly labelSelector?: Array; + + /** + * The page number. Zero indicates no page. + * @type {number} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly page?: number; + + /** + * Size of one page. Zero indicates no limit. + * @type {number} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly size?: number; + + /** + * Sort property and direction of the list result. Support sorting based on attribute name path. + * @type {Array} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly sort?: Array; +} + +/** + * Request parameters for updatesecurityHaloRunV1alpha1PersonalAccessToken operation in SecurityHaloRunV1alpha1PersonalAccessTokenApi. + * @export + * @interface SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessTokenRequest + */ +export interface SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessTokenRequest { + /** + * Name of personalaccesstoken + * @type {string} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly name: string; + + /** + * Updated personalaccesstoken + * @type {PersonalAccessToken} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessToken + */ + readonly personalAccessToken?: PersonalAccessToken; +} + +/** + * SecurityHaloRunV1alpha1PersonalAccessTokenApi - object-oriented interface + * @export + * @class SecurityHaloRunV1alpha1PersonalAccessTokenApi + * @extends {BaseAPI} + */ +export class SecurityHaloRunV1alpha1PersonalAccessTokenApi extends BaseAPI { + /** + * Create security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public createsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiCreatesecurityHaloRunV1alpha1PersonalAccessTokenRequest = {}, + options?: AxiosRequestConfig + ) { + return SecurityHaloRunV1alpha1PersonalAccessTokenApiFp(this.configuration) + .createsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.personalAccessToken, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Delete security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public deletesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiDeletesecurityHaloRunV1alpha1PersonalAccessTokenRequest, + options?: AxiosRequestConfig + ) { + return SecurityHaloRunV1alpha1PersonalAccessTokenApiFp(this.configuration) + .deletesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.name, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Get security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public getsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiGetsecurityHaloRunV1alpha1PersonalAccessTokenRequest, + options?: AxiosRequestConfig + ) { + return SecurityHaloRunV1alpha1PersonalAccessTokenApiFp(this.configuration) + .getsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.name, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * List security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public listsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiListsecurityHaloRunV1alpha1PersonalAccessTokenRequest = {}, + options?: AxiosRequestConfig + ) { + return SecurityHaloRunV1alpha1PersonalAccessTokenApiFp(this.configuration) + .listsecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.fieldSelector, + requestParameters.labelSelector, + requestParameters.page, + requestParameters.size, + requestParameters.sort, + options + ) + .then((request) => request(this.axios, this.basePath)); + } + + /** + * Update security.halo.run/v1alpha1/PersonalAccessToken + * @param {SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessTokenRequest} requestParameters Request parameters. + * @param {*} [options] Override http request option. + * @throws {RequiredError} + * @memberof SecurityHaloRunV1alpha1PersonalAccessTokenApi + */ + public updatesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters: SecurityHaloRunV1alpha1PersonalAccessTokenApiUpdatesecurityHaloRunV1alpha1PersonalAccessTokenRequest, + options?: AxiosRequestConfig + ) { + return SecurityHaloRunV1alpha1PersonalAccessTokenApiFp(this.configuration) + .updatesecurityHaloRunV1alpha1PersonalAccessToken( + requestParameters.name, + requestParameters.personalAccessToken, + options + ) + .then((request) => request(this.axios, this.basePath)); + } +} diff --git a/console/packages/api-client/src/models/index.ts b/console/packages/api-client/src/models/index.ts index 6e100e14f8..88262a54b6 100644 --- a/console/packages/api-client/src/models/index.ts +++ b/console/packages/api-client/src/models/index.ts @@ -91,9 +91,9 @@ export * from "./menu-vo"; export * from "./metadata"; export * from "./navigation-post-vo"; export * from "./owner-info"; +export * from "./pat-spec"; export * from "./personal-access-token"; export * from "./personal-access-token-list"; -export * from "./personal-access-token-spec"; export * from "./plugin"; export * from "./plugin-author"; export * from "./plugin-list"; diff --git a/console/packages/api-client/src/models/pat-spec.ts b/console/packages/api-client/src/models/pat-spec.ts new file mode 100644 index 0000000000..f0929811b5 --- /dev/null +++ b/console/packages/api-client/src/models/pat-spec.ts @@ -0,0 +1,81 @@ +/* tslint:disable */ +/* eslint-disable */ +/** + * Halo Next API + * No description provided (generated by Openapi Generator https://github.com/openapitools/openapi-generator) + * + * The version of the OpenAPI document: 2.0.0 + * + * + * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech). + * https://openapi-generator.tech + * Do not edit the class manually. + */ + +/** + * + * @export + * @interface PatSpec + */ +export interface PatSpec { + /** + * + * @type {string} + * @memberof PatSpec + */ + description?: string; + /** + * + * @type {string} + * @memberof PatSpec + */ + expiresAt?: string; + /** + * + * @type {string} + * @memberof PatSpec + */ + lastUsed?: string; + /** + * + * @type {string} + * @memberof PatSpec + */ + name: string; + /** + * + * @type {boolean} + * @memberof PatSpec + */ + revoked?: boolean; + /** + * + * @type {string} + * @memberof PatSpec + */ + revokesAt?: string; + /** + * + * @type {Array} + * @memberof PatSpec + */ + roles?: Array; + /** + * + * @type {Array} + * @memberof PatSpec + */ + scopes?: Array; + /** + * + * @type {string} + * @memberof PatSpec + */ + tokenId: string; + /** + * + * @type {string} + * @memberof PatSpec + */ + username: string; +} diff --git a/console/packages/api-client/src/models/personal-access-token.ts b/console/packages/api-client/src/models/personal-access-token.ts index 12c33fc330..08385810b4 100644 --- a/console/packages/api-client/src/models/personal-access-token.ts +++ b/console/packages/api-client/src/models/personal-access-token.ts @@ -17,7 +17,7 @@ import { Metadata } from "./metadata"; // May contain unused imports in some cases // @ts-ignore -import { PersonalAccessTokenSpec } from "./personal-access-token-spec"; +import { PatSpec } from "./pat-spec"; /** * @@ -45,8 +45,8 @@ export interface PersonalAccessToken { metadata: Metadata; /** * - * @type {PersonalAccessTokenSpec} + * @type {PatSpec} * @memberof PersonalAccessToken */ - spec?: PersonalAccessTokenSpec; + spec?: PatSpec; } diff --git a/console/packages/api-client/src/models/user-permission.ts b/console/packages/api-client/src/models/user-permission.ts index 7439be2a0a..e057a1745c 100644 --- a/console/packages/api-client/src/models/user-permission.ts +++ b/console/packages/api-client/src/models/user-permission.ts @@ -22,6 +22,12 @@ import { Role } from "./role"; * @interface UserPermission */ export interface UserPermission { + /** + * + * @type {Array} + * @memberof UserPermission + */ + permissions: Array; /** * * @type {Array} diff --git a/console/src/constants/annotations.ts b/console/src/constants/annotations.ts index c3c16a56f9..76ae27bab3 100644 --- a/console/src/constants/annotations.ts +++ b/console/src/constants/annotations.ts @@ -18,3 +18,8 @@ export enum rbacAnnotations { export enum contentAnnotations { PREFERRED_EDITOR = "content.halo.run/preferred-editor", } + +// pat +export enum patAnnotations { + ACCESS_TOKEN = "security.halo.run/access-token", +} diff --git a/console/src/locales/en.yaml b/console/src/locales/en.yaml index 2215f7cedf..4879d9b7bc 100644 --- a/console/src/locales/en.yaml +++ b/console/src/locales/en.yaml @@ -852,6 +852,7 @@ core: title: User detail tabs: detail: Detail + pat: Personal Access Tokens actions: update_profile: title: Update profile @@ -886,6 +887,46 @@ core: flip_horizontal: Flip Horizontal flip_vertical: Flip Vertical reset: Reset + pat: + operations: + delete: + title: Delete Personal Access Token + description: Are you sure you want to delete this personal access token? + revoke: + button: Revoke + title: Revoke Personal Access Token + description: Are you sure you want to revoke this personal access token? + toast_success: Revocation succeeded + copy: + title: Please copy and save immediately, Token will only be displayed once. + restore: + button: Restore + toast_success: Restore successfully + list: + empty: + title: No personal access tokens have been created + message: You can try refreshing or creating a new personal access token + fields: + expiresAt: + dynamic: "Expires on {expiresAt}" + forever: Never expires + status: + normal: Normal + revoked: Revoked + expired: Expired + creation_modal: + title: Create Personal Access Token + groups: + general: General + permissions: Permissions + fields: + name: + label: Name + expiresAt: + label: Expiration Time + help: Leave empty for no expiration + description: + label: Description role: title: Roles common: diff --git a/console/src/locales/zh-CN.yaml b/console/src/locales/zh-CN.yaml index 093a353daf..d11b0eefd8 100644 --- a/console/src/locales/zh-CN.yaml +++ b/console/src/locales/zh-CN.yaml @@ -852,6 +852,7 @@ core: title: 用户详情 tabs: detail: 详情 + pat: 个人令牌 actions: update_profile: title: 修改资料 @@ -886,6 +887,46 @@ core: flip_horizontal: 水平翻转 flip_vertical: 垂直翻转 reset: 重置 + pat: + operations: + delete: + title: 删除个人令牌 + description: 确定要删除该个人令牌吗? + revoke: + button: 撤销 + title: 撤销个人令牌 + description: 确定要撤销该个人令牌吗? + toast_success: 撤销成功 + copy: + title: 请立即复制并保存,Token 将仅显示一次。 + restore: + button: 恢复 + toast_success: 恢复成功 + list: + empty: + title: 当前没有创建个人令牌 + message: 你可以尝试刷新或者新建个人令牌 + fields: + expiresAt: + dynamic: "{expiresAt}失效" + forever: 永久有效 + status: + normal: 正常 + revoked: 已撤销 + expired: 已过期 + creation_modal: + title: 创建个人令牌 + groups: + general: 基本信息 + permissions: 权限 + fields: + name: + label: 名称 + expiresAt: + label: 过期时间 + help: 不设置代表永不过期 + description: + label: 描述 role: title: 角色 common: diff --git a/console/src/locales/zh-TW.yaml b/console/src/locales/zh-TW.yaml index 4f79c03e82..ccd6695eda 100644 --- a/console/src/locales/zh-TW.yaml +++ b/console/src/locales/zh-TW.yaml @@ -852,6 +852,7 @@ core: title: 用戶詳情 tabs: detail: 詳情 + pat: 個人令牌 actions: update_profile: title: 修改資料 @@ -886,6 +887,46 @@ core: flip_horizontal: 水平翻轉 flip_vertical: 垂直翻轉 reset: 重置 + pat: + operations: + delete: + title: 刪除個人令牌 + description: 您確定要刪除此個人令牌嗎? + revoke: + button: 撤銷 + title: 撤銷個人令牌 + description: 您確定要撤銷此個人令牌嗎? + toast_success: 撤銷成功 + copy: + title: 請立即複製並保存,令牌僅顯示一次。 + restore: + button: 還原 + toast_success: 還原成功 + list: + empty: + title: 目前尚未建立個人令牌 + message: 您可以嘗試重新整理或建立新的個人令牌 + fields: + expiresAt: + dynamic: "到期於 {expiresAt}" + forever: 永久有效 + status: + normal: 正常 + revoked: 已撤銷 + expired: 已過期 + creation_modal: + title: 建立個人令牌 + groups: + general: 基本資訊 + permissions: 權限 + fields: + name: + label: 名稱 + expiresAt: + label: 到期時間 + help: 留空代表永不過期 + description: + label: 描述 role: title: 角色 common: diff --git a/console/src/modules/system/roles/RoleDetail.vue b/console/src/modules/system/roles/RoleDetail.vue index df69468aeb..2e19b91d2c 100644 --- a/console/src/modules/system/roles/RoleDetail.vue +++ b/console/src/modules/system/roles/RoleDetail.vue @@ -22,14 +22,27 @@ import { import { SUPER_ROLE_NAME } from "@/constants/constants"; import { useI18n } from "vue-i18n"; import { formatDatetime } from "@/utils/date"; +import { useQuery } from "@tanstack/vue-query"; const route = useRoute(); const { t } = useI18n(); const tabActiveId = ref("detail"); +const { data: roleTemplates } = useQuery({ + queryKey: ["role-templates"], + queryFn: async () => { + const { data } = await apiClient.extension.role.listv1alpha1Role({ + page: 0, + size: 0, + labelSelector: [`${roleLabels.TEMPLATE}=true`, "!halo.run/hidden"], + }); + return data.items; + }, +}); + const { roleTemplateGroups, handleRoleTemplateSelect, selectedRoleTemplates } = - useRoleTemplateSelection(); + useRoleTemplateSelection(roleTemplates); const { formState, saving, handleCreateOrUpdate } = useRoleForm(); diff --git a/console/src/modules/system/roles/components/RoleEditingModal.vue b/console/src/modules/system/roles/components/RoleEditingModal.vue index aa468171fb..743a77b6af 100644 --- a/console/src/modules/system/roles/components/RoleEditingModal.vue +++ b/console/src/modules/system/roles/components/RoleEditingModal.vue @@ -11,8 +11,10 @@ import { import cloneDeep from "lodash.clonedeep"; import { reset } from "@formkit/core"; import { setFocus } from "@/formkit/utils/focus"; -import { pluginLabels } from "@/constants/labels"; +import { pluginLabels, roleLabels } from "@/constants/labels"; import { useI18n } from "vue-i18n"; +import { apiClient } from "@/utils/api-client"; +import { useQuery } from "@tanstack/vue-query"; const { t } = useI18n(); @@ -32,8 +34,20 @@ const emit = defineEmits<{ (event: "close"): void; }>(); +const { data: roleTemplates } = useQuery({ + queryKey: ["role-templates"], + queryFn: async () => { + const { data } = await apiClient.extension.role.listv1alpha1Role({ + page: 0, + size: 0, + labelSelector: [`${roleLabels.TEMPLATE}=true`, "!halo.run/hidden"], + }); + return data.items; + }, +}); + const { roleTemplateGroups, handleRoleTemplateSelect, selectedRoleTemplates } = - useRoleTemplateSelection(); + useRoleTemplateSelection(roleTemplates); const { formState, diff --git a/console/src/modules/system/roles/composables/use-role.ts b/console/src/modules/system/roles/composables/use-role.ts index 4b8d643ab6..480bd8c1a2 100644 --- a/console/src/modules/system/roles/composables/use-role.ts +++ b/console/src/modules/system/roles/composables/use-role.ts @@ -43,7 +43,6 @@ interface useRoleFormReturn { interface useRoleTemplateSelectionReturn { selectedRoleTemplates: Ref>; - roleTemplates: Ref; roleTemplateGroups: ComputedRef; handleRoleTemplateSelect: (e: Event) => void; } @@ -157,8 +156,9 @@ export function useRoleForm(): useRoleFormReturn { * * @returns {useRoleTemplateSelectionReturn} */ -export function useRoleTemplateSelection(): useRoleTemplateSelectionReturn { - const roleTemplates = ref([] as Role[]); +export function useRoleTemplateSelection( + roleTemplates: Ref +): useRoleTemplateSelectionReturn { const selectedRoleTemplates = ref>(new Set()); /** @@ -246,7 +246,7 @@ export function useRoleTemplateSelection(): useRoleTemplateSelectionReturn { */ const roleTemplateGroups = computed(() => { const groups: RoleTemplateGroup[] = []; - roleTemplates.value.forEach((role) => { + roleTemplates.value?.forEach((role) => { const group = groups.find( (group) => group.module === role.metadata.annotations?.[rbacAnnotations.MODULE] @@ -263,28 +263,12 @@ export function useRoleTemplateSelection(): useRoleTemplateSelectionReturn { return groups; }); - /** - * Get all role templates based on the condition that `metadata.labels.[halo.run/role-template] = 'true'` and `!halo.run/hidden` - */ - const handleFetchRoles = async () => { - try { - const { data } = await apiClient.extension.role.listv1alpha1Role({ - page: 0, - size: 0, - labelSelector: [`${roleLabels.TEMPLATE}=true`, "!halo.run/hidden"], - }); - roleTemplates.value = data.items; - } catch (e) { - console.error(e); - } - }; - const handleRoleTemplateSelect = async (e: Event) => { const { checked, value } = e.target as HTMLInputElement; if (!checked) { return; } - const role = roleTemplates.value.find( + const role = roleTemplates.value?.find( (role) => role.metadata.name === value ); const dependencies = @@ -298,11 +282,8 @@ export function useRoleTemplateSelection(): useRoleTemplateSelectionReturn { }); }; - onMounted(handleFetchRoles); - return { selectedRoleTemplates, - roleTemplates, roleTemplateGroups, handleRoleTemplateSelect, }; diff --git a/console/src/modules/system/users/PersonalAccessTokens.vue b/console/src/modules/system/users/PersonalAccessTokens.vue index 7a39f94d8b..55a3275470 100644 --- a/console/src/modules/system/users/PersonalAccessTokens.vue +++ b/console/src/modules/system/users/PersonalAccessTokens.vue @@ -1,292 +1,103 @@ diff --git a/console/src/modules/system/users/components/PersonalAccessTokenCreationModal.vue b/console/src/modules/system/users/components/PersonalAccessTokenCreationModal.vue new file mode 100644 index 0000000000..8655f522ac --- /dev/null +++ b/console/src/modules/system/users/components/PersonalAccessTokenCreationModal.vue @@ -0,0 +1,279 @@ + + + diff --git a/console/src/modules/system/users/components/PersonalAccessTokenListItem.vue b/console/src/modules/system/users/components/PersonalAccessTokenListItem.vue new file mode 100644 index 0000000000..f4792d382c --- /dev/null +++ b/console/src/modules/system/users/components/PersonalAccessTokenListItem.vue @@ -0,0 +1,149 @@ + + + diff --git a/console/src/modules/system/users/layouts/UserProfileLayout.vue b/console/src/modules/system/users/layouts/UserProfileLayout.vue index 24c2de8bb1..eaeaf367cb 100644 --- a/console/src/modules/system/users/layouts/UserProfileLayout.vue +++ b/console/src/modules/system/users/layouts/UserProfileLayout.vue @@ -53,18 +53,13 @@ const { currentUserHasPermission } = usePermission(); const userStore = useUserStore(); const { t } = useI18n(); -const tabs = [ +const tabs = ref([ { id: "detail", label: t("core.user.detail.tabs.detail"), routeName: "UserDetail", }, - // { - // id: "tokens", - // label: "个人令牌", - // routeName: "PersonalAccessTokens", - // }, -]; +]); const editingModal = ref(false); const passwordChangeModal = ref(false); @@ -122,6 +117,20 @@ const isCurrentUser = computed(() => { provide>("user", user); provide>("isCurrentUser", isCurrentUser); +// fixme: refactor this component to simplify the logic +watch( + () => isCurrentUser.value, + (value) => { + if (value) { + tabs.value.push({ + id: "tokens", + label: t("core.user.detail.tabs.pat"), + routeName: "PersonalAccessTokens", + }); + } + } +); + const activeTab = ref(); const route = useRoute(); @@ -129,20 +138,20 @@ const router = useRouter(); // set default active tab onMounted(() => { - const tab = tabs.find((tab) => tab.routeName === route.name); - activeTab.value = tab ? tab.id : tabs[0].id; + const tab = tabs.value.find((tab) => tab.routeName === route.name); + activeTab.value = tab ? tab.id : tabs.value[0].id; }); watch( () => route.name, async (newRouteName) => { - const tab = tabs.find((tab) => tab.routeName === newRouteName); - activeTab.value = tab ? tab.id : tabs[0].id; + const tab = tabs.value.find((tab) => tab.routeName === newRouteName); + activeTab.value = tab ? tab.id : tabs.value[0].id; } ); const handleTabChange = (id: string) => { - const tab = tabs.find((tab) => tab.id === id); + const tab = tabs.value.find((tab) => tab.id === id); if (tab) { router.push({ name: tab.routeName }); } diff --git a/console/src/stores/role.ts b/console/src/stores/role.ts index 208b976c17..26af3a9bfc 100644 --- a/console/src/stores/role.ts +++ b/console/src/stores/role.ts @@ -5,6 +5,7 @@ import { ref } from "vue"; export const useRoleStore = defineStore("role", () => { const permissions = ref({ roles: [], + permissions: [], uiPermissions: [], }); diff --git a/console/src/utils/api-client.ts b/console/src/utils/api-client.ts index 52eb57261f..7c0d5dacc0 100644 --- a/console/src/utils/api-client.ts +++ b/console/src/utils/api-client.ts @@ -28,7 +28,6 @@ import { V1alpha1ConfigMapApi, V1alpha1MenuApi, V1alpha1MenuItemApi, - V1alpha1PersonalAccessTokenApi, V1alpha1RoleApi, V1alpha1RoleBindingApi, V1alpha1SettingApi, @@ -41,6 +40,8 @@ import { ApiHaloRunV1alpha1UserApi, MigrationHaloRunV1alpha1BackupApi, ApiConsoleMigrationHaloRunV1alpha1MigrationApi, + ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi, + SecurityHaloRunV1alpha1PersonalAccessTokenApi, } from "@halo-dev/api-client"; import type { AxiosError, AxiosInstance } from "axios"; import axios from "axios"; @@ -117,11 +118,6 @@ function setupApiClient(axios: AxiosInstance) { return { extension: { configMap: new V1alpha1ConfigMapApi(undefined, baseURL, axios), - personalAccessToken: new V1alpha1PersonalAccessTokenApi( - undefined, - baseURL, - axios - ), roleBinding: new V1alpha1RoleBindingApi(undefined, baseURL, axios), role: new V1alpha1RoleApi(undefined, baseURL, axios), setting: new V1alpha1SettingApi(undefined, baseURL, axios), @@ -184,6 +180,11 @@ function setupApiClient(axios: AxiosInstance) { axios ), backup: new MigrationHaloRunV1alpha1BackupApi(undefined, baseURL, axios), + pat: new SecurityHaloRunV1alpha1PersonalAccessTokenApi( + undefined, + baseURL, + axios + ), }, // custom endpoints user: new ApiConsoleHaloRunV1alpha1UserApi(undefined, baseURL, axios), @@ -220,6 +221,11 @@ function setupApiClient(axios: AxiosInstance) { axios ), system: new ApiConsoleHaloRunV1alpha1SystemApi(undefined, baseURL, axios), + pat: new ApiConsoleSecurityHaloRunV1alpha1PersonalAccessTokenApi( + undefined, + baseURL, + axios + ), }; } diff --git a/console/vite.config.ts b/console/vite.config.ts index af4e2a21b5..b741bbccf3 100644 --- a/console/vite.config.ts +++ b/console/vite.config.ts @@ -11,7 +11,11 @@ import VueI18nPlugin from "@intlify/unplugin-vue-i18n/vite"; import GzipPlugin from "rollup-plugin-gzip"; export const sharedPlugins = [ - Vue(), + Vue({ + script: { + defineModel: true, + }, + }), VueJsx(), GzipPlugin() as Plugin, Icons({ diff --git a/docs/authentication/README.md b/docs/authentication/README.md index 149203f339..b323294b64 100644 --- a/docs/authentication/README.md +++ b/docs/authentication/README.md @@ -6,6 +6,7 @@ - 表单登录(Form Login) 计划支持的认证方式有: + - [个人令牌认证(Personal Access Token)](https://github.com/halo-dev/halo/issues/1309) - [OAuth2](https://oauth.net/2/) @@ -28,12 +29,12 @@ YWRtaW46UEA4OHcwcmQ= - 表单参数 - | 参数名 | 类型 | 说明 | - | ---------- | ------ | ------------------------------------- | - | username | form | 用户名 | - | password | form | 密码 | - | _csrf | form | `CSRF` 令牌。由客户端随机生成。 | - | XSRF-TOKEN | cookie | 跨站请求伪造令牌,和 `_csrf` 的值一致 | + | 参数名 | 类型 | 说明 | + | ---------- | ------ | ------------------------------------- | + | username | form | 用户名 | + | password | form | 密码 | + | _csrf | form | `CSRF` 令牌。由客户端随机生成。 | + | XSRF-TOKEN | cookie | 跨站请求伪造令牌,和 `_csrf` 的值一致 | - HTTP 200 响应 @@ -80,7 +81,7 @@ YWRtaW46UEA4OHcwcmQ= ``` - HTTP 302 响应 - + 仅在请求头 `Accept` 中不包含 `application/json`才会发生,响应示例如下所示: ```bash @@ -109,3 +110,119 @@ YWRtaW46UEA4OHcwcmQ= ``` 未来计划支持“记住我(Remember Me)”功能。 + +## Personal Access Token + +### 背景 + +Halo 是一款现代化的开源 CMS / 建站系统,为了便于开发者和用户利用 API 访问网站数据,Halo 支持了 Personal Access Token(以下简称 +PAT)功能。 +用户可以在 Halo 的后台生成 PAT,它是一个随机字符串,用于在 API 请求头里提供验证身份用。Halo 后端在接收请求时会校验 PAT +的值,如果匹配就会允许访问相应的 API 数据。 +这种 PAT 机制避免了直接使用用户名密码的安全隐患,开发者可以为每个 PAT 设置访问范围、过期时间等。同时使用随机 PAT +也增加了安全性。这为开发 Halo 插件和应用提供了更安全简便的认证方式。 +相比直接暴露服务端 API,这种 PAT 机制也更标准化和安全可控。Halo 在参考业内主流做法的基础上,引入了 PAT,以便于生态系统的开放与丰富。 + +### 设计 + +PAT 以 `pat_` 开头,剩余部分为随机字符串,随机字符串可以是 [JWT](https://datatracker.ietf.org/doc/html/rfc7519)、UUID +或其他经过加密的随机字符串。目前,Halo 的实现是 `pat_` + `JWT` 的形式,例如: + +```text +pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbInN1cGVyLXJvbGUiXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tSVdvbFEiLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0NjcyMDc5LCJpYXQiOjE2OTQ1ODU3MjAsImp0aSI6IjE3ZWFkNzlkLTRkMjctYjg4NS02YjAzLTM4Y2JlYzQxMmFlMyJ9.xiq36NZIM3_ynBx-l0scGdfX-89aJi6uV7HJz_kNnuT78CFmxD-XTpncK1E-hqPdQSrSwyG4gT1pVO17UmUCoyoAkZKKKVk_seFwxdbygIueo2UJA5kVw1Naf_6iLtNkAXxAiYUpd8ihIwvVedhmOMQ9UUfd4QKZDR1XnTW4EAteWBi7b0pWqSa4h5lv7TpmAECY_KDAGrBRGGhc9AxsrGYPNZo68n2QGJ5BjH29vfdQaZz4vwsgKxG1WJ9Y7c8cQI9JN8EyQD_n560NWAaoFnRi1qL3nexvhjq8EVyGVyM48aKA02UcyvI9cxZFk6ZgnzmUsMjyA6ZL7wuexkujVqmc3iO5plBDCjW7oMe1zPQq-gEJXJU6gdr_SHcGG1BjamoekCkOeNT3CPzA_-5j3AVlj7FTFQkbn_h-kV07mfNO45BVVKsMb08HrN6iEk7TOX7SxN0s2gFc3xYVcXBMveLtftOfXs04SvSFCfTDeJH_Jy-3lYb_GLOji7xSc6FgRbuAwmzHLlsgBT4NJhR_0dZ-jNsCDIQCIC3iDc0qbcNTJYYocT77YaQzIkleFIXyPiV0RsNPmSTEDGiDlctsZ-AmcGCDQ-UmW8SIFBrA93OHncvb47o0-uBwZLdF_we4S90hJlNiAPVhhrBMtCoTJotyrODMEzwbLIukvewFXp8 +``` + +示例 Token 中 JWT 部分所对应的 Header 如下: + +```json +{ + "kid": "ZmCmqhI_anhYVAnZFUSKINrLWDXjhJu6OYDdmqmwFz8", + "alg": "RS256" +} +``` + +Payload 如下: + +```json +{ + "sub": "admin", + "roles": [ + "super-role" + ], + "pat_name": "pat-admin-IWolQ", + "iss": "http://localhost:8090/", + "exp": 1694672079, + "iat": 1694585720, + "jti": "17ead79d-4d27-b885-6b03-38cbec412ae3" +} +``` + +### 使用方式 + +#### 生成 PAT + +Halo 专门提供了生成 PAT 的端口:`/apis/api.console.security.halo.run/v1alpha1/users/-/personalaccesstokens`。创建 PAT +请求示例如下: + +```shell +curl -u admin:admin -X 'POST' \ + 'http://localhost:8090/apis/api.console.security.halo.run/v1alpha1/users/-/personalaccesstokens' \ + -H 'accept: application/json' \ + -H 'Content-Type: application/json' \ + -d '{ + "spec": { + "name": "My PAT", + "description": "This is my first PAT.", + "expiresAt": "2023-09-15T02:42:35.136Z" + "roles": [""] + } +}' +``` + +```json +{ + "spec": { + "description": "This is my first PAT.", + "expiresAt": "2023-09-16T02:42:35.136Z", + "roles": [], + "username": "admin", + "revoked": false, + "tokenId": "0b897d9c-56d7-5541-2662-110b70e3f9fd" + }, + "apiVersion": "security.halo.run/v1alpha1", + "kind": "PersonalAccessToken", + "metadata": { + "generateName": "pat-admin-", + "name": "pat-admin-lobkm", + "annotations": { + "security.halo.run/access-token": "pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tbG9ia20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0ODMyMTU1LCJpYXQiOjE2OTQ3NDcyOTgsImp0aSI6IjBiODk3ZDljLTU2ZDctNTU0MS0yNjYyLTExMGI3MGUzZjlmZCJ9.UVFYzKmz3bUk7fV6xh_CpuNJA-BR8bci-DIJ7o0fk-hayHXFHr_-7HMrVn7iZcphryqmk0RLv7Zsu_AjY9Qn9iCYybBJBycU0tUJzhDexRtj1ViJtlsraoYxLNSYpJK1hcPngeJuiMa9FZrYGp0k_7GX1NddoXLUBI9orN9DbdKmmJXtvigaxPCp52Mu7fBtVsTmO5fk_y2CglqRl_tkLRpFSgUbERKOqKItctDFRg-WUALBYEpXbhZIXBMuTCsJwhniBMpc1Uu_a1Dqa3K5hDgfHTeUADY2BuhEdYJCODPCzmdfWMNqxYSKQT5JFYoDv-ed6cRqNjKeNvd1IPT3RDkVt_fbo8KPrzvkgIjIzni-Wlwe-pXXQbj_n8iax-jkeK526iu8q2CLptxYxLGD0j8htKZramrov4UkK_eIsotEZZfqig9sYVU5_b442WhOWatdB_pbKj7h-YK1Cb2ueg5kl73bcbBu63b8edJZClp6xr72az343SfBZdwrT_JJ5HR0hJmckAMR_U4qvGWrJ-dobXDgY9Oz-qObfiyglzn0Wrz4HRPlmqDFr2o6TMV7UVjQiV77tDzaNbaXVevXGPS5MaZr313dia7XLpIV3QopXma7rDR6Xnqg7ftDQb5vAvsjwN-JsVabAsdFeCo6ejE1slAD9ZQrD88kgfAIuX4" + }, + "version": 0, + "creationTimestamp": "2023-09-15T03:08:18.875350Z" + } +} +``` + +请求体说明如下表所示: + +| 属性名 | 描述 | +|-------------|----------------------------------------------------------------------------------------------------| +| name | PAT 名称。必填。 | +| description | PAT 描述。非必填。 | +| expiresAt | PAT 过期时间,一旦创建不可修改,或修改无效。如果不填写,则表示 PAT 无过期时间。 | +| roles | 授权给 PAT 的角色,必须包含在当前用户所拥有的角色内。如果设置为 `null` 或者 `[]`,则表示当前 PAT 仅会拥有 `anonymous` 和 `authenticated` 角色。 | + +响应体说明如下所示: + +| 属性路径 | 描述 | +|-----------------------------------------------------|----------------------------------------------| +| metadata.annotations.security.halo.run/access-token | 生成好的 PAT。需要注意的是,这个 PAT 不会保存在数据库中,所以仅有一次保存机会。 | + +#### 使用 PAT + +向 Halo 发送请求时,携带 Header:`Authorization: Bearer $PAT` 即可。示例如下: + +```shell +curl http://localhost:8090/apis/api.console.halo.run/v1alpha1/users/- \ + -H "Authorization: Bearer pat_eyJraWQiOiJabUNtcWhJX2FuaFlWQW5aRlVTS0lOckxXRFhqaEp1Nk9ZRGRtcW13Rno4IiwiYWxnIjoiUlMyNTYifQ.eyJzdWIiOiJhZG1pbiIsInJvbGVzIjpbXSwicGF0X25hbWUiOiJwYXQtYWRtaW4tbG9ia20iLCJpc3MiOiJodHRwOi8vbG9jYWxob3N0OjgwOTAvIiwiZXhwIjoxNjk0ODMyMTU1LCJpYXQiOjE2OTQ3NDcyOTgsImp0aSI6IjBiODk3ZDljLTU2ZDctNTU0MS0yNjYyLTExMGI3MGUzZjlmZCJ9.UVFYzKmz3bUk7fV6xh_CpuNJA-BR8bci-DIJ7o0fk-hayHXFHr_-7HMrVn7iZcphryqmk0RLv7Zsu_AjY9Qn9iCYybBJBycU0tUJzhDexRtj1ViJtlsraoYxLNSYpJK1hcPngeJuiMa9FZrYGp0k_7GX1NddoXLUBI9orN9DbdKmmJXtvigaxPCp52Mu7fBtVsTmO5fk_y2CglqRl_tkLRpFSgUbERKOqKItctDFRg-WUALBYEpXbhZIXBMuTCsJwhniBMpc1Uu_a1Dqa3K5hDgfHTeUADY2BuhEdYJCODPCzmdfWMNqxYSKQT5JFYoDv-ed6cRqNjKeNvd1IPT3RDkVt_fbo8KPrzvkgIjIzni-Wlwe-pXXQbj_n8iax-jkeK526iu8q2CLptxYxLGD0j8htKZramrov4UkK_eIsotEZZfqig9sYVU5_b442WhOWatdB_pbKj7h-YK1Cb2ueg5kl73bcbBu63b8edJZClp6xr72az343SfBZdwrT_JJ5HR0hJmckAMR_U4qvGWrJ-dobXDgY9Oz-qObfiyglzn0Wrz4HRPlmqDFr2o6TMV7UVjQiV77tDzaNbaXVevXGPS5MaZr313dia7XLpIV3QopXma7rDR6Xnqg7ftDQb5vAvsjwN-JsVabAsdFeCo6ejE1slAD9ZQrD88kgfAIuX4" +```