feat: implement @RequiredAal and @Privileged annotations to secure controllers

This commit is contained in:
MiniDigger | Martin 2023-04-09 11:08:02 +02:00
parent 1ba8f4fd2b
commit 049a882958
15 changed files with 187 additions and 23 deletions

View File

@ -40,6 +40,8 @@ import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.exceptions.HangarResponseException;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.security.annotations.aal.RequireAal;
import io.papermc.hangar.security.annotations.privileged.Privileged;
import io.papermc.hangar.security.annotations.ratelimit.RateLimit;
import io.papermc.hangar.security.annotations.unlocked.Unlocked;
import io.papermc.hangar.service.internal.users.UserService;
@ -106,7 +108,8 @@ public class CredentialController extends HangarComponent {
* WEBAUTHN
*/
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping(value = "/webauthn/setup", produces = MediaType.APPLICATION_JSON_VALUE, consumes = MediaType.TEXT_PLAIN_VALUE)
public String setupWebauthn(@RequestBody final String authenticatorName) throws JsonProcessingException {
// TODO verify that backup codes exist
@ -130,7 +133,8 @@ public class CredentialController extends HangarComponent {
return response.publicKeyCredentialCreationOptions().toCredentialsCreateJson();
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping(value = "/webauthn/register", consumes = MediaType.TEXT_PLAIN_VALUE)
@ResponseStatus(HttpStatus.OK)
public void registerWebauthn(@RequestBody final String publicKeyCredentialJson, @RequestHeader(value = "X-Hangar-Verify", required = false) final String header) throws IOException {
@ -176,7 +180,8 @@ public class CredentialController extends HangarComponent {
return assertionRequest.toCredentialsGetJson();
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping(value = "/webauthn/unregister", consumes = MediaType.TEXT_PLAIN_VALUE)
public void unregisterWebauthnDevice(@RequestBody final String id) {
this.webAuthNService.removeDevice(this.getHangarPrincipal().getUserId(), id);
@ -187,7 +192,8 @@ public class CredentialController extends HangarComponent {
* TOTP
*/
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping("/totp/setup")
public TotpSetupResponse setupTotp() throws QrGenerationException {
final String secret = this.secretGenerator.generate();
@ -206,7 +212,8 @@ public class CredentialController extends HangarComponent {
return new TotpSetupResponse(secret, qrCodeImage);
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping("/totp/register")
public ResponseEntity<?> registerTotp(@RequestBody final TotpForm form, @RequestHeader(value = "X-Hangar-Verify", required = false) final String header) {
final boolean confirmCodes = this.verifyBackupCodes(header);
@ -230,7 +237,8 @@ public class CredentialController extends HangarComponent {
return ResponseEntity.ok().build();
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping("/totp/remove")
@ResponseStatus(HttpStatus.OK)
public void removeTotp() {
@ -239,7 +247,8 @@ public class CredentialController extends HangarComponent {
this.credentialsService.checkRemoveBackupCodes();
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping("/totp/verify")
@ResponseStatus(HttpStatus.OK)
public void verifyTotp(@RequestBody final String code) {
@ -298,7 +307,8 @@ public class CredentialController extends HangarComponent {
return codes;
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping("/codes/show")
public List<BackupCodeCredential.BackupCode> showBackupCodes() {
// TODO security protection
@ -309,7 +319,8 @@ public class CredentialController extends HangarComponent {
return cred.backupCodes();
}
@Unlocked
@Privileged
@RequireAal(1)
@PostMapping("/codes/regenerate")
public List<BackupCodeCredential.BackupCode> regenerateBackupCodes() {
// TODO security protection

View File

@ -0,0 +1,24 @@
package io.papermc.hangar.security.annotations.aal;
import java.util.Collection;
import java.util.Set;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.annotation.AnnotationMetadataExtractor;
import org.springframework.stereotype.Component;
@Component
public class AalMetadataExtractor implements AnnotationMetadataExtractor<RequireAal> {
@Override
public Collection<? extends ConfigAttribute> extractAttributes(final RequireAal annotation) {
return Set.of(new AalAttribute(annotation.value()));
}
record AalAttribute(int aal) implements ConfigAttribute {
@Override
public String getAttribute() {
return String.valueOf(this.aal);
}
}
}

View File

@ -0,0 +1,34 @@
package io.papermc.hangar.security.annotations.aal;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.security.annotations.HangarDecisionVoter;
import io.papermc.hangar.security.authentication.HangarAuthenticationToken;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component
public class AalUnlockedVoter extends HangarDecisionVoter<AalMetadataExtractor.AalAttribute> {
public AalUnlockedVoter() {
super(AalMetadataExtractor.AalAttribute.class);
}
@Override
public int vote(final Authentication authentication, final MethodInvocation object, final @NotNull AalMetadataExtractor.AalAttribute attribute) {
if (!(authentication instanceof HangarAuthenticationToken)) {
return ACCESS_DENIED;
}
final int aal = ((HangarAuthenticationToken) authentication).getPrincipal().getAal();
if (aal < attribute.aal()) {
if (attribute.aal() == 1) {
throw new HangarApiException(HttpStatus.UNAUTHORIZED, "error.aal1");
} else {
throw new HangarApiException(HttpStatus.UNAUTHORIZED, "error.aal2");
}
}
return ACCESS_GRANTED;
}
}

View File

@ -0,0 +1,18 @@
package io.papermc.hangar.security.annotations.aal;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.security.access.annotation.Secured;
/**
* Require the user be logged in AND have a certain aal
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Secured("ROLE_USER")
public @interface RequireAal {
int value();
}

View File

@ -22,8 +22,8 @@ public class CurrentUserMetadataExtractor implements AnnotationMetadataExtractor
record CurrentUserAttribute(Expression expression) implements ConfigAttribute {
@Override
public String getAttribute() {
return null;
}
public String getAttribute() {
return null;
}
}
}

View File

@ -19,7 +19,7 @@ public class CurrentUserVoter extends HangarDecisionVoter<CurrentUserMetadataExt
@Override
public int vote(final Authentication authentication, final MethodInvocation methodInvocation, final @NotNull CurrentUserMetadataExtractor.CurrentUserAttribute attribute) {
if (!(authentication instanceof HangarAuthenticationToken hangarAuthenticationToken)) {
if (!(authentication instanceof final HangarAuthenticationToken hangarAuthenticationToken)) {
return ACCESS_DENIED;
}
if (hangarAuthenticationToken.getPrincipal().isAllowedGlobal(Permission.EditAllUserSettings)) {

View File

@ -29,8 +29,8 @@ public class PermissionRequiredMetadataExtractor implements AnnotationMetadataEx
record PermissionRequiredAttribute(PermissionType permissionType, NamedPermission[] permissions, Expression expression) implements ConfigAttribute {
@Override
public String getAttribute() {
return null;
}
public String getAttribute() {
return null;
}
}
}

View File

@ -29,7 +29,7 @@ public class PermissionRequiredVoter extends HangarDecisionVoter<PermissionRequi
@Override
public int vote(final Authentication authentication, final MethodInvocation methodInvocation, final Set<PermissionRequiredMetadataExtractor.PermissionRequiredAttribute> attributes) {
if (!(authentication instanceof HangarAuthenticationToken hangarAuthenticationToken)) {
if (!(authentication instanceof final HangarAuthenticationToken hangarAuthenticationToken)) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
for (final PermissionRequiredMetadataExtractor.PermissionRequiredAttribute attribute : attributes) {

View File

@ -0,0 +1,16 @@
package io.papermc.hangar.security.annotations.privileged;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import org.springframework.security.access.annotation.Secured;
/**
* Require the user be logged in AND unlocked
*/
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Secured("ROLE_USER")
public @interface Privileged {
}

View File

@ -0,0 +1,29 @@
package io.papermc.hangar.security.annotations.privileged;
import java.util.Collection;
import java.util.Set;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.access.annotation.AnnotationMetadataExtractor;
import org.springframework.stereotype.Component;
@Component
public class PrivilegedMetadataExtractor implements AnnotationMetadataExtractor<Privileged> {
@Override
public Collection<? extends ConfigAttribute> extractAttributes(final Privileged securityAnnotation) {
return Set.of(PrivilegedUnlockedAttribute.INSTANCE);
}
static final class PrivilegedUnlockedAttribute implements ConfigAttribute {
static final PrivilegedUnlockedAttribute INSTANCE = new PrivilegedUnlockedAttribute();
private PrivilegedUnlockedAttribute() {
}
@Override
public String getAttribute() {
return "PRIVILEGED";
}
}
}

View File

@ -0,0 +1,29 @@
package io.papermc.hangar.security.annotations.privileged;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.security.annotations.HangarDecisionVoter;
import io.papermc.hangar.security.authentication.HangarAuthenticationToken;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
@Component
public class PrivilegedVoter extends HangarDecisionVoter<PrivilegedMetadataExtractor.PrivilegedUnlockedAttribute> {
public PrivilegedVoter() {
super(PrivilegedMetadataExtractor.PrivilegedUnlockedAttribute.class);
}
@Override
public int vote(final Authentication authentication, final MethodInvocation object, final @NotNull PrivilegedMetadataExtractor.PrivilegedUnlockedAttribute attribute) {
if (!(authentication instanceof HangarAuthenticationToken)) {
return ACCESS_DENIED;
}
if (!((HangarAuthenticationToken) authentication).getPrincipal().isPrivileged()) {
throw new HangarApiException(HttpStatus.UNAUTHORIZED, "error.privileged");
}
return ACCESS_GRANTED;
}
}

View File

@ -14,7 +14,7 @@ public class UnlockedMetadataExtractor implements AnnotationMetadataExtractor<Un
return Set.of(UnlockedAttribute.INSTANCE);
}
static class UnlockedAttribute implements ConfigAttribute {
static final class UnlockedAttribute implements ConfigAttribute {
static final UnlockedAttribute INSTANCE = new UnlockedAttribute();

View File

@ -22,8 +22,8 @@ public class VisibilityRequiredMetadataExtractor implements AnnotationMetadataEx
record VisibilityRequiredAttribute(VisibilityRequired.Type type, Expression expression) implements ConfigAttribute {
@Override
public String getAttribute() {
return null;
}
public String getAttribute() {
return null;
}
}
}

View File

@ -1115,6 +1115,9 @@
"401": "You must be logged in for this",
"403": "You do not have permission to do that",
"404": "404 Not found",
"unknown": "An error occurred"
"unknown": "An error occurred",
"aal1": "You have to verify your email to do this",
"aal2": "You have to setup 2fa to do this",
"privileged": "You need to be in a privileged session to do this"
}
}

View File

@ -105,7 +105,7 @@ async function loginBackupCode() {
}
async function finish(response: LoginResponse) {
if (response.aal && response.user?.accessToken) {
if (response.aal !== undefined && response.user?.accessToken) {
authStore.aal = response.aal;
authStore.user = response.user;
authStore.authenticated = true;