Extract PAT operation with service (#7341)

#### What type of PR is this?

/kind improvement
/area core
/milestone 2.20.x

#### What this PR does / why we need it:

This PR refactors UserScopedPatHandlerImpl with PAT service to make PAT operations flexible.

#### Does this PR introduce a user-facing change?

```release-note
None
```
pull/7350/head
John Niang 2025-04-14 18:22:12 +08:00 committed by GitHub
parent 067e3d58e1
commit 3a5e4f82b4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 366 additions and 169 deletions

View File

@ -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

View File

@ -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<PersonalAccessToken> 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<PersonalAccessToken> 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<PersonalAccessToken> 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<PersonalAccessToken> 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<PersonalAccessToken> 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<PersonalAccessToken> get(String patName, String username);
/**
* Generate a personal access token.
*
* @param pat the personal access token
* @return the generated token
*/
Mono<String> generateToken(PersonalAccessToken pat);
}

View File

@ -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<PersonalAccessToken> 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<PersonalAccessToken> create(PersonalAccessToken patRequest, String username) {
return userDetailsService.findByUsername(username)
.flatMap(userDetails ->
create(patRequest, username, userDetails.getAuthorities())
);
}
private Mono<PersonalAccessToken> create(PersonalAccessToken patRequest, String username,
Collection<? extends GrantedAuthority> 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<PersonalAccessToken> 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<PersonalAccessToken> 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<PersonalAccessToken> delete(String patName, String username) {
return get(patName, username)
.flatMap(client::delete);
}
@Override
public Mono<PersonalAccessToken> 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<String> 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<Boolean> hasSufficientRoles(
Collection<? extends GrantedAuthority> grantedAuthorities, List<String> requestRoles) {
if (CollectionUtils.isEmpty(requestRoles)) {
return Mono.just(true);
}
var grantedRoles = AuthorityUtils.authoritiesToRoles(grantedAuthorities);
return roleService.contains(grantedRoles, requestRoles);
}
}

View File

@ -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<Authentication> convert(ServerWebExchange exchange) {
return super.convert(exchange)

View File

@ -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<Authentication> mustBeAuthenticated(Mono<Authentication> 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<ServerResponse> 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<Boolean> hasSufficientRoles(
Collection<? extends GrantedAuthority> grantedAuthorities, List<String> requestRoles) {
if (CollectionUtils.isEmpty(requestRoles)) {
return Mono.just(true);
}
var grantedRoles = AuthorityUtils.authoritiesToRoles(grantedAuthorities);
return roleService.contains(grantedRoles, requestRoles);
}
private Mono<PersonalAccessToken> 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));
}
}