diff --git a/api/src/main/java/run/halo/app/security/PersonalAccessToken.java b/api/src/main/java/run/halo/app/security/PersonalAccessToken.java index 333c225b1..a33842595 100644 --- a/api/src/main/java/run/halo/app/security/PersonalAccessToken.java +++ b/api/src/main/java/run/halo/app/security/PersonalAccessToken.java @@ -20,6 +20,8 @@ public class PersonalAccessToken extends AbstractExtension { public static final String KIND = "PersonalAccessToken"; + public static final String PAT_TOKEN_PREFIX = "pat_"; + private Spec spec = new Spec(); @Data diff --git a/application/src/main/java/run/halo/app/core/user/service/PatService.java b/application/src/main/java/run/halo/app/core/user/service/PatService.java new file mode 100644 index 000000000..1ab1b6fb9 --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/PatService.java @@ -0,0 +1,75 @@ +package run.halo.app.core.user.service; + +import reactor.core.publisher.Mono; +import run.halo.app.security.PersonalAccessToken; + +/** + * Service for personal access token. + * + * @author johnniang + */ +public interface PatService { + + /** + * Create a new personal access token. We will automatically use the current user as the + * owner of the token from the security context. + * + * @param patRequest the personal access token request + * @return the created personal access token + */ + Mono create(PersonalAccessToken patRequest); + + /** + * Create a new personal access token for the specified user. + * + * @param patRequest the personal access token request + * @param username the username of the user + * @return the created personal access token + */ + Mono create(PersonalAccessToken patRequest, String username); + + /** + * Revoke a personal access token. + * + * @param patName the name of the personal access token + * @param username the username of the user + * @return the revoked personal access token + */ + Mono revoke(String patName, String username); + + /** + * Restore a personal access token. + * + * @param patName the name of the personal access token + * @param username the username of the user + * @return the restored personal access token + */ + Mono restore(String patName, String username); + + /** + * Delete a personal access token. + * + * @param patName the name of the personal access token + * @param username the username of the user + * @return the deleted personal access token + */ + Mono delete(String patName, String username); + + /** + * Get a personal access token by name. + * + * @param patName the name of the personal access token + * @param username the username of the user + * @return the personal access token + */ + Mono get(String patName, String username); + + /** + * Generate a personal access token. + * + * @param pat the personal access token + * @return the generated token + */ + Mono generateToken(PersonalAccessToken pat); + +} diff --git a/application/src/main/java/run/halo/app/core/user/service/impl/PatServiceImpl.java b/application/src/main/java/run/halo/app/core/user/service/impl/PatServiceImpl.java new file mode 100644 index 000000000..7378cb38b --- /dev/null +++ b/application/src/main/java/run/halo/app/core/user/service/impl/PatServiceImpl.java @@ -0,0 +1,257 @@ +package run.halo.app.core.user.service.impl; + +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import java.time.Clock; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Objects; +import org.springframework.security.authentication.AuthenticationTrustResolver; +import org.springframework.security.authentication.AuthenticationTrustResolverImpl; +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.ReactiveUserDetailsService; +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.filter.reactive.ServerWebExchangeContextFilter; +import org.springframework.web.server.ServerWebInputException; +import reactor.core.publisher.Mono; +import run.halo.app.core.user.service.PatService; +import run.halo.app.core.user.service.RoleService; +import run.halo.app.extension.Metadata; +import run.halo.app.extension.ReactiveExtensionClient; +import run.halo.app.infra.ExternalUrlSupplier; +import run.halo.app.infra.exception.NotFoundException; +import run.halo.app.security.PersonalAccessToken; +import run.halo.app.security.authentication.CryptoService; +import run.halo.app.security.authorization.AuthorityUtils; + +/** + * Service for managing personal access tokens (PATs). + * + * @author johnniang + */ +@Service +class PatServiceImpl implements PatService { + + private final RoleService roleService; + + private final IdGenerator idGenerator; + + private final ReactiveExtensionClient client; + + private final AuthenticationTrustResolver authTrustResolver = + new AuthenticationTrustResolverImpl(); + + private final JwtEncoder jwtEncoder; + + private final ExternalUrlSupplier externalUrl; + + private final ReactiveUserDetailsService userDetailsService; + + private final String keyId; + + private Clock clock; + + public PatServiceImpl(RoleService roleService, + ReactiveExtensionClient client, + ExternalUrlSupplier externalUrl, + CryptoService cryptoService, ReactiveUserDetailsService userDetailsService) { + this.roleService = roleService; + this.client = client; + this.externalUrl = externalUrl; + this.userDetailsService = userDetailsService; + this.clock = Clock.systemUTC(); + idGenerator = new AlternativeJdkIdGenerator(); + var jwk = cryptoService.getJwk(); + this.jwtEncoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk))); + this.keyId = jwk.getKeyID(); + } + + /** + * Set clock for testing. + * + * @param clock the clock to set + */ + void setClock(Clock clock) { + this.clock = clock; + } + + @Override + public Mono create(PersonalAccessToken patRequest) { + return ReactiveSecurityContextHolder.getContext() + .map(SecurityContext::getAuthentication) + // TODO We only allow authenticated users to create PATs. + .filter(authTrustResolver::isAuthenticated) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Authentication required.")) + ) + .flatMap(auth -> + create(patRequest, auth.getName(), auth.getAuthorities()) + ); + } + + @Override + public Mono create(PersonalAccessToken patRequest, String username) { + return userDetailsService.findByUsername(username) + .flatMap(userDetails -> + create(patRequest, username, userDetails.getAuthorities()) + ); + } + + private Mono create(PersonalAccessToken patRequest, String username, + Collection authorities) { + var patSpec = patRequest.getSpec(); + // preflight check + var expiresAt = patSpec.getExpiresAt(); + if (expiresAt != null && expiresAt.isBefore(clock.instant())) { + return Mono.error(new ServerWebInputException("Invalid expiresAt.")); + } + var roles = patSpec.getRoles(); + return hasSufficientRoles(authorities, roles) + .filter(has -> has) + .switchIfEmpty( + Mono.error(() -> new ServerWebInputException("Insufficient roles.")) + ) + .map(has -> { + var pat = new PersonalAccessToken(); + pat.setMetadata(new Metadata()); + if (patRequest.getMetadata() != null) { + var metadata = patRequest.getMetadata(); + if (metadata.getName() != null) { + pat.getMetadata().setName(metadata.getName()); + } + if (metadata.getGenerateName() != null) { + pat.getMetadata().setGenerateName(metadata.getGenerateName()); + } + if (metadata.getLabels() != null) { + pat.getMetadata().setLabels(new HashMap<>()); + pat.getMetadata().getLabels().putAll(metadata.getLabels()); + } + if (metadata.getAnnotations() != null) { + pat.getMetadata().setAnnotations(new HashMap<>()); + pat.getMetadata().getAnnotations() + .putAll(metadata.getAnnotations()); + } + if (metadata.getFinalizers() != null) { + pat.getMetadata().setFinalizers(new HashSet<>()); + pat.getMetadata().getFinalizers().addAll(metadata.getFinalizers()); + } + } + if (pat.getMetadata().getGenerateName() == null) { + pat.getMetadata().setGenerateName("pat-" + username + "-"); + } + pat.getSpec().setUsername(username); + pat.getSpec().setName(patSpec.getName()); + pat.getSpec().setDescription(patSpec.getDescription()); + if (patSpec.getRoles() != null) { + pat.getSpec().setRoles(new ArrayList<>()); + pat.getSpec().getRoles().addAll(patSpec.getRoles()); + } + if (patSpec.getScopes() != null) { + pat.getSpec().setScopes(new ArrayList<>()); + pat.getSpec().getScopes().addAll(patSpec.getScopes()); + } + pat.getSpec().setExpiresAt(patSpec.getExpiresAt()); + pat.getSpec().setTokenId(idGenerator.generateId().toString()); + return pat; + }) + .flatMap(client::create); + } + + @Override + public Mono revoke(String patName, String username) { + return get(patName, username) + .filter(pat -> !pat.getSpec().isRevoked()) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The token has been revoked before.")) + ) + .doOnNext(pat -> { + pat.getSpec().setRevoked(true); + pat.getSpec().setRevokesAt(clock.instant()); + }) + .flatMap(client::update); + } + + @Override + public Mono restore(String patName, String username) { + return get(patName, username) + .filter(pat -> pat.getSpec().isRevoked()) + .switchIfEmpty(Mono.error( + () -> new ServerWebInputException("The token has not been revoked before.")) + ) + .doOnNext(pat -> { + pat.getSpec().setRevoked(false); + pat.getSpec().setRevokesAt(null); + }) + .flatMap(client::update); + } + + @Override + public Mono delete(String patName, String username) { + return get(patName, username) + .flatMap(client::delete); + } + + @Override + public Mono get(String patName, String username) { + return client.fetch(PersonalAccessToken.class, patName) + .filter(pat -> Objects.equals(pat.getSpec().getUsername(), username)) + .switchIfEmpty(Mono.error(() -> new NotFoundException( + "The personal access token was not found or deleted." + ))); + } + + @Override + public Mono generateToken(PersonalAccessToken pat) { + return Mono.deferContextual( + contextView -> { + var externalUrl = ServerWebExchangeContextFilter.getExchange(contextView) + .map(exchange -> this.externalUrl.getURL(exchange.getRequest())) + .orElse(null); + if (externalUrl == null) { + return Mono.error(new ServerWebInputException("Server web exchange is " + + "required")); + } + var claimsBuilder = JwtClaimsSet.builder() + .issuer(externalUrl.toString()) + .id(pat.getSpec().getTokenId()) + .subject(pat.getSpec().getUsername()) + .issuedAt(clock.instant()) + .claim("pat_name", pat.getMetadata().getName()); + var expiresAt = pat.getSpec().getExpiresAt(); + if (expiresAt != null) { + claimsBuilder.expiresAt(expiresAt); + } + var headerBuilder = JwsHeader.with(SignatureAlgorithm.RS256) + .keyId(this.keyId); + var jwt = jwtEncoder.encode(JwtEncoderParameters.from( + headerBuilder.build(), + claimsBuilder.build())); + return Mono.just(jwt); + } + ) + .map(jwt -> PersonalAccessToken.PAT_TOKEN_PREFIX + jwt.getTokenValue()); + } + + 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); + } +} 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 index ab5ec828a..d8cb72381 100644 --- 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 @@ -1,5 +1,7 @@ package run.halo.app.security.authentication.pat; +import static run.halo.app.security.PersonalAccessToken.PAT_TOKEN_PREFIX; + import org.apache.commons.lang3.StringUtils; import org.springframework.security.core.Authentication; import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken; @@ -15,8 +17,6 @@ import reactor.core.publisher.Mono; */ class PatAuthenticationConverter extends ServerBearerTokenAuthenticationConverter { - public static final String PAT_TOKEN_PREFIX = "pat_"; - @Override public Mono convert(ServerWebExchange exchange) { return super.convert(exchange) diff --git a/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java index ce71890ab..a3d61ccc4 100644 --- a/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java +++ b/application/src/main/java/run/halo/app/security/authentication/pat/UserScopedPatHandlerImpl.java @@ -1,91 +1,41 @@ package run.halo.app.security.authentication.pat; import static run.halo.app.extension.Comparators.compareCreationTimestamp; -import static run.halo.app.security.authentication.pat.PatAuthenticationConverter.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 org.springframework.security.authentication.AuthenticationTrustResolver; import org.springframework.security.authentication.AuthenticationTrustResolverImpl; 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.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.user.service.RoleService; -import run.halo.app.extension.ExtensionUtil; -import run.halo.app.extension.Metadata; +import run.halo.app.core.user.service.PatService; 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.CryptoService; -import run.halo.app.security.authorization.AuthorityUtils; @Service class UserScopedPatHandlerImpl implements UserScopedPatHandler { 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; + private final PatService patService; private final AuthenticationTrustResolver authTrustResolver = new AuthenticationTrustResolverImpl(); public UserScopedPatHandlerImpl(ReactiveExtensionClient client, - CryptoService cryptoService, - ExternalUrlSupplier externalUrl, - RoleService roleService) { + PatService patService) { this.client = client; - this.externalUrl = externalUrl; - this.roleService = roleService; - - var patJwk = cryptoService.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; + this.patService = patService; } private Mono mustBeAuthenticated(Mono authentication) { @@ -99,71 +49,20 @@ class UserScopedPatHandlerImpl implements UserScopedPatHandler { return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .transform(this::mustBeAuthenticated) - .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("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())); - 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)); - })); + .flatMap(auth -> request.bodyToMono(PersonalAccessToken.class)) + .switchIfEmpty(Mono.error(() -> new ServerWebInputException("Missing request body."))) + .flatMap(patService::create) + .flatMap(pat -> patService.generateToken(pat) + .doOnNext(token -> { + if (pat.getMetadata().getAnnotations() == null) { + pat.getMetadata().setAnnotations(new HashMap<>()); + } + pat.getMetadata().getAnnotations() + .put(ACCESS_TOKEN_ANNO_NAME, token); + }) + .thenReturn(pat) + ) + .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } @Override @@ -185,9 +84,9 @@ class UserScopedPatHandlerImpl implements UserScopedPatHandler { .map(SecurityContext::getAuthentication) .flatMap(auth -> { var name = request.pathVariable("name"); - var pat = getPat(name, auth.getName()); - return ServerResponse.ok().body(pat, PersonalAccessToken.class); - }); + return patService.get(name, auth.getName()); + }) + .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } @Override @@ -196,18 +95,9 @@ class UserScopedPatHandlerImpl implements UserScopedPatHandler { .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); - }); + return patService.revoke(name, auth.getName()); + }) + .flatMap(revokedPat -> ServerResponse.ok().bodyValue(revokedPat)); } @Override @@ -216,48 +106,21 @@ class UserScopedPatHandlerImpl implements UserScopedPatHandler { .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); - }); + return patService.delete(name, auth.getName()); + }) + .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } @Override public Mono restore(ServerRequest request) { - var restoredPat = ReactiveSecurityContextHolder.getContext() + return ReactiveSecurityContextHolder.getContext() .map(SecurityContext::getAuthentication) .transform(this::mustBeAuthenticated) .flatMap(auth -> { var name = request.pathVariable("name"); - return getPat(name, auth.getName()); + return patService.restore(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); + .flatMap(pat -> ServerResponse.ok().bodyValue(pat)); } - 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 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)); - } }