Skip to content

Commit 2e4f04a

Browse files
committed
Refactor PAT endpoint with PAT service
1 parent 067e3d5 commit 2e4f04a

File tree

5 files changed

+366
-169
lines changed

5 files changed

+366
-169
lines changed

api/src/main/java/run/halo/app/security/PersonalAccessToken.java

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@ public class PersonalAccessToken extends AbstractExtension {
2020

2121
public static final String KIND = "PersonalAccessToken";
2222

23+
public static final String PAT_TOKEN_PREFIX = "pat_";
24+
2325
private Spec spec = new Spec();
2426

2527
@Data
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package run.halo.app.core.user.service;
2+
3+
import reactor.core.publisher.Mono;
4+
import run.halo.app.security.PersonalAccessToken;
5+
6+
/**
7+
* Service for personal access token.
8+
*
9+
* @author johnniang
10+
*/
11+
public interface PatService {
12+
13+
/**
14+
* Create a new personal access token. We will automatically use the current user as the
15+
* owner of the token from the security context.
16+
*
17+
* @param patRequest the personal access token request
18+
* @return the created personal access token
19+
*/
20+
Mono<PersonalAccessToken> create(PersonalAccessToken patRequest);
21+
22+
/**
23+
* Create a new personal access token for the specified user.
24+
*
25+
* @param patRequest the personal access token request
26+
* @param username the username of the user
27+
* @return the created personal access token
28+
*/
29+
Mono<PersonalAccessToken> create(PersonalAccessToken patRequest, String username);
30+
31+
/**
32+
* Revoke a personal access token.
33+
*
34+
* @param patName the name of the personal access token
35+
* @param username the username of the user
36+
* @return the revoked personal access token
37+
*/
38+
Mono<PersonalAccessToken> revoke(String patName, String username);
39+
40+
/**
41+
* Restore a personal access token.
42+
*
43+
* @param patName the name of the personal access token
44+
* @param username the username of the user
45+
* @return the restored personal access token
46+
*/
47+
Mono<PersonalAccessToken> restore(String patName, String username);
48+
49+
/**
50+
* Delete a personal access token.
51+
*
52+
* @param patName the name of the personal access token
53+
* @param username the username of the user
54+
* @return the deleted personal access token
55+
*/
56+
Mono<PersonalAccessToken> delete(String patName, String username);
57+
58+
/**
59+
* Get a personal access token by name.
60+
*
61+
* @param patName the name of the personal access token
62+
* @param username the username of the user
63+
* @return the personal access token
64+
*/
65+
Mono<PersonalAccessToken> get(String patName, String username);
66+
67+
/**
68+
* Generate a personal access token.
69+
*
70+
* @param pat the personal access token
71+
* @return the generated token
72+
*/
73+
Mono<String> generateToken(PersonalAccessToken pat);
74+
75+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,257 @@
1+
package run.halo.app.core.user.service.impl;
2+
3+
import com.nimbusds.jose.jwk.JWKSet;
4+
import com.nimbusds.jose.jwk.source.ImmutableJWKSet;
5+
import java.time.Clock;
6+
import java.util.ArrayList;
7+
import java.util.Collection;
8+
import java.util.HashMap;
9+
import java.util.HashSet;
10+
import java.util.List;
11+
import java.util.Objects;
12+
import org.springframework.security.authentication.AuthenticationTrustResolver;
13+
import org.springframework.security.authentication.AuthenticationTrustResolverImpl;
14+
import org.springframework.security.core.GrantedAuthority;
15+
import org.springframework.security.core.context.ReactiveSecurityContextHolder;
16+
import org.springframework.security.core.context.SecurityContext;
17+
import org.springframework.security.core.userdetails.ReactiveUserDetailsService;
18+
import org.springframework.security.oauth2.jose.jws.SignatureAlgorithm;
19+
import org.springframework.security.oauth2.jwt.JwsHeader;
20+
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
21+
import org.springframework.security.oauth2.jwt.JwtEncoder;
22+
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
23+
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
24+
import org.springframework.stereotype.Service;
25+
import org.springframework.util.AlternativeJdkIdGenerator;
26+
import org.springframework.util.CollectionUtils;
27+
import org.springframework.util.IdGenerator;
28+
import org.springframework.web.filter.reactive.ServerWebExchangeContextFilter;
29+
import org.springframework.web.server.ServerWebInputException;
30+
import reactor.core.publisher.Mono;
31+
import run.halo.app.core.user.service.PatService;
32+
import run.halo.app.core.user.service.RoleService;
33+
import run.halo.app.extension.Metadata;
34+
import run.halo.app.extension.ReactiveExtensionClient;
35+
import run.halo.app.infra.ExternalUrlSupplier;
36+
import run.halo.app.infra.exception.NotFoundException;
37+
import run.halo.app.security.PersonalAccessToken;
38+
import run.halo.app.security.authentication.CryptoService;
39+
import run.halo.app.security.authorization.AuthorityUtils;
40+
41+
/**
42+
* Service for managing personal access tokens (PATs).
43+
*
44+
* @author johnniang
45+
*/
46+
@Service
47+
class PatServiceImpl implements PatService {
48+
49+
private final RoleService roleService;
50+
51+
private final IdGenerator idGenerator;
52+
53+
private final ReactiveExtensionClient client;
54+
55+
private final AuthenticationTrustResolver authTrustResolver =
56+
new AuthenticationTrustResolverImpl();
57+
58+
private final JwtEncoder jwtEncoder;
59+
60+
private final ExternalUrlSupplier externalUrl;
61+
62+
private final ReactiveUserDetailsService userDetailsService;
63+
64+
private final String keyId;
65+
66+
private Clock clock;
67+
68+
public PatServiceImpl(RoleService roleService,
69+
ReactiveExtensionClient client,
70+
ExternalUrlSupplier externalUrl,
71+
CryptoService cryptoService, ReactiveUserDetailsService userDetailsService) {
72+
this.roleService = roleService;
73+
this.client = client;
74+
this.externalUrl = externalUrl;
75+
this.userDetailsService = userDetailsService;
76+
this.clock = Clock.systemUTC();
77+
idGenerator = new AlternativeJdkIdGenerator();
78+
var jwk = cryptoService.getJwk();
79+
this.jwtEncoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk)));
80+
this.keyId = jwk.getKeyID();
81+
}
82+
83+
/**
84+
* Set clock for testing.
85+
*
86+
* @param clock the clock to set
87+
*/
88+
void setClock(Clock clock) {
89+
this.clock = clock;
90+
}
91+
92+
@Override
93+
public Mono<PersonalAccessToken> create(PersonalAccessToken patRequest) {
94+
return ReactiveSecurityContextHolder.getContext()
95+
.map(SecurityContext::getAuthentication)
96+
// TODO We only allow authenticated users to create PATs.
97+
.filter(authTrustResolver::isAuthenticated)
98+
.switchIfEmpty(
99+
Mono.error(() -> new ServerWebInputException("Authentication required."))
100+
)
101+
.flatMap(auth ->
102+
create(patRequest, auth.getName(), auth.getAuthorities())
103+
);
104+
}
105+
106+
@Override
107+
public Mono<PersonalAccessToken> create(PersonalAccessToken patRequest, String username) {
108+
return userDetailsService.findByUsername(username)
109+
.flatMap(userDetails ->
110+
create(patRequest, username, userDetails.getAuthorities())
111+
);
112+
}
113+
114+
private Mono<PersonalAccessToken> create(PersonalAccessToken patRequest, String username,
115+
Collection<? extends GrantedAuthority> authorities) {
116+
var patSpec = patRequest.getSpec();
117+
// preflight check
118+
var expiresAt = patSpec.getExpiresAt();
119+
if (expiresAt != null && expiresAt.isBefore(clock.instant())) {
120+
return Mono.error(new ServerWebInputException("Invalid expiresAt."));
121+
}
122+
var roles = patSpec.getRoles();
123+
return hasSufficientRoles(authorities, roles)
124+
.filter(has -> has)
125+
.switchIfEmpty(
126+
Mono.error(() -> new ServerWebInputException("Insufficient roles."))
127+
)
128+
.map(has -> {
129+
var pat = new PersonalAccessToken();
130+
pat.setMetadata(new Metadata());
131+
if (patRequest.getMetadata() != null) {
132+
var metadata = patRequest.getMetadata();
133+
if (metadata.getName() != null) {
134+
pat.getMetadata().setName(metadata.getName());
135+
}
136+
if (metadata.getGenerateName() != null) {
137+
pat.getMetadata().setGenerateName(metadata.getGenerateName());
138+
}
139+
if (metadata.getLabels() != null) {
140+
pat.getMetadata().setLabels(new HashMap<>());
141+
pat.getMetadata().getLabels().putAll(metadata.getLabels());
142+
}
143+
if (metadata.getAnnotations() != null) {
144+
pat.getMetadata().setAnnotations(new HashMap<>());
145+
pat.getMetadata().getAnnotations()
146+
.putAll(metadata.getAnnotations());
147+
}
148+
if (metadata.getFinalizers() != null) {
149+
pat.getMetadata().setFinalizers(new HashSet<>());
150+
pat.getMetadata().getFinalizers().addAll(metadata.getFinalizers());
151+
}
152+
}
153+
if (pat.getMetadata().getGenerateName() == null) {
154+
pat.getMetadata().setGenerateName("pat-" + username + "-");
155+
}
156+
pat.getSpec().setUsername(username);
157+
pat.getSpec().setName(patSpec.getName());
158+
pat.getSpec().setDescription(patSpec.getDescription());
159+
if (patSpec.getRoles() != null) {
160+
pat.getSpec().setRoles(new ArrayList<>());
161+
pat.getSpec().getRoles().addAll(patSpec.getRoles());
162+
}
163+
if (patSpec.getScopes() != null) {
164+
pat.getSpec().setScopes(new ArrayList<>());
165+
pat.getSpec().getScopes().addAll(patSpec.getScopes());
166+
}
167+
pat.getSpec().setExpiresAt(patSpec.getExpiresAt());
168+
pat.getSpec().setTokenId(idGenerator.generateId().toString());
169+
return pat;
170+
})
171+
.flatMap(client::create);
172+
}
173+
174+
@Override
175+
public Mono<PersonalAccessToken> revoke(String patName, String username) {
176+
return get(patName, username)
177+
.filter(pat -> !pat.getSpec().isRevoked())
178+
.switchIfEmpty(Mono.error(
179+
() -> new ServerWebInputException("The token has been revoked before."))
180+
)
181+
.doOnNext(pat -> {
182+
pat.getSpec().setRevoked(true);
183+
pat.getSpec().setRevokesAt(clock.instant());
184+
})
185+
.flatMap(client::update);
186+
}
187+
188+
@Override
189+
public Mono<PersonalAccessToken> restore(String patName, String username) {
190+
return get(patName, username)
191+
.filter(pat -> pat.getSpec().isRevoked())
192+
.switchIfEmpty(Mono.error(
193+
() -> new ServerWebInputException("The token has not been revoked before."))
194+
)
195+
.doOnNext(pat -> {
196+
pat.getSpec().setRevoked(false);
197+
pat.getSpec().setRevokesAt(null);
198+
})
199+
.flatMap(client::update);
200+
}
201+
202+
@Override
203+
public Mono<PersonalAccessToken> delete(String patName, String username) {
204+
return get(patName, username)
205+
.flatMap(client::delete);
206+
}
207+
208+
@Override
209+
public Mono<PersonalAccessToken> get(String patName, String username) {
210+
return client.fetch(PersonalAccessToken.class, patName)
211+
.filter(pat -> Objects.equals(pat.getSpec().getUsername(), username))
212+
.switchIfEmpty(Mono.error(() -> new NotFoundException(
213+
"The personal access token was not found or deleted."
214+
)));
215+
}
216+
217+
@Override
218+
public Mono<String> generateToken(PersonalAccessToken pat) {
219+
return Mono.deferContextual(
220+
contextView -> {
221+
var externalUrl = ServerWebExchangeContextFilter.getExchange(contextView)
222+
.map(exchange -> this.externalUrl.getURL(exchange.getRequest()))
223+
.orElse(null);
224+
if (externalUrl == null) {
225+
return Mono.error(new ServerWebInputException("Server web exchange is "
226+
+ "required"));
227+
}
228+
var claimsBuilder = JwtClaimsSet.builder()
229+
.issuer(externalUrl.toString())
230+
.id(pat.getSpec().getTokenId())
231+
.subject(pat.getSpec().getUsername())
232+
.issuedAt(clock.instant())
233+
.claim("pat_name", pat.getMetadata().getName());
234+
var expiresAt = pat.getSpec().getExpiresAt();
235+
if (expiresAt != null) {
236+
claimsBuilder.expiresAt(expiresAt);
237+
}
238+
var headerBuilder = JwsHeader.with(SignatureAlgorithm.RS256)
239+
.keyId(this.keyId);
240+
var jwt = jwtEncoder.encode(JwtEncoderParameters.from(
241+
headerBuilder.build(),
242+
claimsBuilder.build()));
243+
return Mono.just(jwt);
244+
}
245+
)
246+
.map(jwt -> PersonalAccessToken.PAT_TOKEN_PREFIX + jwt.getTokenValue());
247+
}
248+
249+
private Mono<Boolean> hasSufficientRoles(
250+
Collection<? extends GrantedAuthority> grantedAuthorities, List<String> requestRoles) {
251+
if (CollectionUtils.isEmpty(requestRoles)) {
252+
return Mono.just(true);
253+
}
254+
var grantedRoles = AuthorityUtils.authoritiesToRoles(grantedAuthorities);
255+
return roleService.contains(grantedRoles, requestRoles);
256+
}
257+
}

application/src/main/java/run/halo/app/security/authentication/pat/PatAuthenticationConverter.java

+2-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
package run.halo.app.security.authentication.pat;
22

3+
import static run.halo.app.security.PersonalAccessToken.PAT_TOKEN_PREFIX;
4+
35
import org.apache.commons.lang3.StringUtils;
46
import org.springframework.security.core.Authentication;
57
import org.springframework.security.oauth2.server.resource.authentication.BearerTokenAuthenticationToken;
@@ -15,8 +17,6 @@
1517
*/
1618
class PatAuthenticationConverter extends ServerBearerTokenAuthenticationConverter {
1719

18-
public static final String PAT_TOKEN_PREFIX = "pat_";
19-
2020
@Override
2121
public Mono<Authentication> convert(ServerWebExchange exchange) {
2222
return super.convert(exchange)

0 commit comments

Comments
 (0)