From f9ebe5398b913dfd00c2217a6c33898bd8e3630a Mon Sep 17 00:00:00 2001 From: John Niang Date: Fri, 22 Sep 2023 11:59:21 +0800 Subject: [PATCH] Support personal access token mechanism --- .../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 +++ docs/authentication/README.md | 131 +++++++- 27 files changed, 1277 insertions(+), 226 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 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/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" +```