api key authentication

This commit is contained in:
Jake Potrebic 2021-04-06 14:12:48 -07:00
parent 8009325e2f
commit 7b490320fa
No known key found for this signature in database
GPG Key ID: 7C58557EC9C421F8
26 changed files with 328 additions and 233 deletions

View File

@ -2,6 +2,7 @@ package io.papermc.hangar.controller.api.v1;
import io.papermc.hangar.controller.api.v1.interfaces.IApiKeysController;
import io.papermc.hangar.model.internal.api.requests.CreateAPIKeyForm;
import io.papermc.hangar.security.annotations.permission.PermissionRequired;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.ResponseBody;
@ -13,6 +14,7 @@ public class ApiKeysController implements IApiKeysController {
@Override
@ResponseBody
@ResponseStatus(HttpStatus.CREATED)
@PermissionRequired()
public String createKey(CreateAPIKeyForm apiKeyForm) {
// TODO implement
System.out.println(apiKeyForm);

View File

@ -3,24 +3,25 @@ package io.papermc.hangar.controller.api.v1;
import io.papermc.hangar.controller.HangarController;
import io.papermc.hangar.controller.api.v1.interfaces.IAuthenticationController;
import io.papermc.hangar.model.api.auth.ApiSession;
import io.papermc.hangar.model.api.requests.SessionProperties;
import io.papermc.hangar.service.AuthenticationService;
import org.apache.commons.lang3.NotImplementedException;
import io.papermc.hangar.security.annotations.Anyone;
import io.papermc.hangar.service.api.APIAuthenticationService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
@Controller
public class AuthenticationController extends HangarController implements IAuthenticationController {
private final AuthenticationService authenticationService;
private final APIAuthenticationService apiAuthenticationService;
public AuthenticationController(AuthenticationService authenticationService) {
this.authenticationService = authenticationService;
@Autowired
public AuthenticationController(APIAuthenticationService apiAuthenticationService) {
this.apiAuthenticationService = apiAuthenticationService;
}
// TODO JWT
@Anyone
@Override
public ResponseEntity<ApiSession> authenticate(SessionProperties body) {
throw new NotImplementedException("Setup JWT here");
public ResponseEntity<ApiSession> authenticate(String apiKey) {
return ResponseEntity.ok(apiAuthenticationService.createJWTForApiKey(apiKey));
}
}

View File

@ -1,13 +1,5 @@
package io.papermc.hangar.controller.api.v1;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import java.time.OffsetDateTime;
import java.util.Map;
import io.papermc.hangar.controller.HangarController;
import io.papermc.hangar.controller.api.v1.interfaces.IProjectsController;
import io.papermc.hangar.controller.extras.pagination.annotations.ApplicableFilters;
@ -22,7 +14,16 @@ import io.papermc.hangar.model.api.project.Project;
import io.papermc.hangar.model.api.project.ProjectMember;
import io.papermc.hangar.model.api.project.ProjectSortingStrategy;
import io.papermc.hangar.model.api.requests.RequestPagination;
import io.papermc.hangar.security.annotations.visibility.VisibilityRequired;
import io.papermc.hangar.security.annotations.visibility.VisibilityRequired.Type;
import io.papermc.hangar.service.api.ProjectsApiService;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import java.time.OffsetDateTime;
import java.util.Map;
@Controller
public class ProjectsController extends HangarController implements IProjectsController {
@ -35,11 +36,13 @@ public class ProjectsController extends HangarController implements IProjectsCon
}
@Override
@VisibilityRequired(type = Type.PROJECT, args = "{#author, #slug}")
public ResponseEntity<Project> getProject(String author, String slug) {
return ResponseEntity.ok(projectsApiService.getProject(author, slug));
}
@Override
@VisibilityRequired(type = Type.PROJECT, args = "{#author, #slug}")
public ResponseEntity<PaginatedResult<ProjectMember>> getProjectMembers(String author, String slug, @NotNull RequestPagination pagination) {
return ResponseEntity.ok(projectsApiService.getProjectMembers(author, slug, pagination));
}

View File

@ -1,7 +1,6 @@
package io.papermc.hangar.controller.api.v1.interfaces;
import io.papermc.hangar.model.api.auth.ApiSession;
import io.papermc.hangar.model.api.requests.SessionProperties;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import io.swagger.annotations.ApiParam;
@ -11,26 +10,26 @@ import io.swagger.annotations.Authorization;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
@Api(tags = "Sessions (Authentication)", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE)
@RequestMapping(path = "/api/v1", consumes = MediaType.APPLICATION_JSON_VALUE, produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.POST)
@Api(tags = "Sessions (Authentication)", produces = MediaType.APPLICATION_JSON_VALUE)
@RequestMapping(path = "/api/v1", produces = MediaType.APPLICATION_JSON_VALUE, method = RequestMethod.POST)
public interface IAuthenticationController {
@ApiOperation(
value = "Creates an API session",
value = "Creates an API JWT",
nickname = "authenticate",
notes = "Creates a new API session. Pass an API key to create an authenticated session. To create a public session, don't pass an Authorization header. When passing an API key, you should use the scheme `HangarApi`, and parameter `apikey`. An example would be `Authorization: HangarApi apikey=\"foobar\"`. The returned session should be specified in all following request as the parameter `session`. An example would be `Authorization: HangarApi session=\"noisses\"`",
notes = "Creates a new API JWT. Pass an API key to create an authenticated session. When passing an API key, you should use the scheme `HangarAuth`, and parameter `apikey`. An example would be `Authorization: HangarAuth <apikey>`. The returned JWT should be specified in all following request as the parameter `token`.",
authorizations = @Authorization(value = "Key"),
tags = "Sessions (Authentication)"
)
@ApiResponses({
@ApiResponse(code = 200, message = "Ok"),
@ApiResponse(code = 400, message = "Sent if the requested expiration can't be used."),
@ApiResponse(code = 400, message = "Bad Request"),
@ApiResponse(code = 401, message = "Api key missing or invalid")
})
@PostMapping("/authenticate")
ResponseEntity<ApiSession> authenticate(@ApiParam("Session properties") @RequestBody(required = false) SessionProperties body);
ResponseEntity<ApiSession> authenticate(@ApiParam("JWT") @RequestParam String apiKey);
}

View File

@ -153,11 +153,11 @@ public class ProjectController extends HangarController {
}
@Anyone
@VisibilityRequired(type = Type.PROJECT, args = "{#author, #name}")
@GetMapping(value = "/project/{author}/{name}/icon", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})
public Object getProjectIcon(@PathVariable String author, @PathVariable String name) {
@VisibilityRequired(type = Type.PROJECT, args = "{#author, #slug}")
@GetMapping(value = "/project/{author}/{slug}/icon", produces = {MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE})
public Object getProjectIcon(@PathVariable String author, @PathVariable String slug) {
try {
return imageService.getProjectIcon(author, name);
return imageService.getProjectIcon(author, slug);
} catch (InternalHangarException e) {
return new RedirectView(imageService.getUserIcon(author));
}

View File

@ -11,6 +11,7 @@ import org.jdbi.v3.sqlobject.statement.SqlUpdate;
import org.springframework.stereotype.Repository;
@Repository
@RegisterColumnMapper(PermissionMapper.class)
@RegisterConstructorMapper(ApiKeyTable.class)
public interface ApiKeyDAO {
@ -21,11 +22,12 @@ public interface ApiKeyDAO {
@SqlUpdate("DELETE FROM api_keys WHERE name = :keyName AND owner_id = :userId")
int delete(String keyName, long userId);
@RegisterColumnMapper(PermissionMapper.class)
@SqlQuery("SELECT *, raw_key_permissions::bigint permissions FROM api_keys WHERE owner_id = :userId AND lower(name) = lower(:name)")
ApiKeyTable getByUserAndName(long userId, String name);
// @SqlQuery("SELECT *, raw_key_permissions::BIGINT permissions FROM api_keys k WHERE k.token_identifier = :identifier AND k.token = crypt(:token, k.token)")
// ApiKeyTable findApiKey(String identifier, String token);
// 1318e930-ef32-4034-88bd-967285a9d28b.f22f2f94-e3a5-496c-8ff7-5230ed16c8a6
@SqlQuery("SELECT *, raw_key_permissions::bigint permissions FROM api_keys WHERE token_identifier = :identifier AND token = crypt(:token, token)")
ApiKeyTable findApiKey(String identifier, String token);
@SqlQuery("SELECT *, raw_key_permissions::bigint permissions FROM api_keys WHERE owner_id = :userId AND token_identifier = :identifier")
ApiKeyTable findApiKey(long userId, String identifier);
}

View File

@ -118,8 +118,8 @@ public interface ProjectsApiDAO {
" JOIN roles r ON upr.role_type = r.name " +
" WHERE p.slug = :slug AND p.owner_name = :author " +
" GROUP BY u.name ORDER BY max(r.permission::BIGINT) DESC " +
" LIMIT :limit OFFSET :offset")
List<ProjectMember> getProjectMembers(String author, String slug, long limit, long offset);
" <offsetLimit>")
List<ProjectMember> getProjectMembers(String author, String slug, @BindPagination RequestPagination pagination);
@SqlQuery("SELECT count(*) " +
" FROM projects p " +

View File

@ -1,62 +1,28 @@
package io.papermc.hangar.model.api.auth;
import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonValue;
import java.time.OffsetDateTime;
public class ApiSession {
private final String session;
private final OffsetDateTime expires;
private final SessionType type;
private final String token;
private final long expiresIn;
public ApiSession(String session, OffsetDateTime expires, SessionType type) {
this.session = session;
this.expires = expires;
this.type = type;
public ApiSession(String token, long expiresIn) {
this.token = token;
this.expiresIn = expiresIn;
}
public String getSession() {
return session;
public String getToken() {
return token;
}
public OffsetDateTime getExpires() {
return expires;
public long getExpiresIn() {
return expiresIn;
}
public SessionType getType() {
return type;
}
public enum SessionType {
KEY("key"),
USER("user"),
PUBLIC("public"),
DEV("dev");
public static final SessionType[] VALUES = values();
private final String value;
SessionType(String value) {
this.value = value;
}
@JsonValue
@Override
public String toString() {
return value;
}
@JsonCreator
public static SessionType fromValue(String value) {
for (SessionType sessionType : SessionType.VALUES) {
if (sessionType.value.equals(value)) {
return sessionType;
}
}
return null;
}
@Override
public String toString() {
return "ApiSession{" +
"token='" + token + '\'' +
", expiresIn=" + expiresIn +
'}';
}
}

View File

@ -77,6 +77,10 @@ public class Permission implements Comparable<Permission>, Argument {
return new Permission(value | other.value);
}
public Permission intersect(Permission other) {
return new Permission(value & other.value);
}
public Permission remove(Permission other) {
return new Permission(value & ~other.value);
}

View File

@ -1,24 +1,46 @@
package io.papermc.hangar.security.annotations;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.NotNull;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.core.DefaultParameterNameDiscoverer;
import org.springframework.core.ParameterNameDiscoverer;
import org.springframework.security.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import java.util.Collection;
import java.util.Set;
import java.util.stream.Collectors;
/**
* Must override one of
* <ul>
* <li>{@link #vote(Authentication, MethodInvocation, ConfigAttribute)}</li>
* <li>{@link #vote(Authentication, MethodInvocation, Set)}</li>
* </ul>
* @param <A>
*/
public abstract class HangarDecisionVoter<A extends ConfigAttribute> implements AccessDecisionVoter<MethodInvocation> {
private final Class<A> attributeClass;
protected final ParameterNameDiscoverer parameterNameDiscoverer;
private final ParameterNameDiscoverer parameterNameDiscoverer;
private boolean allowMultipleAttributes = false;
private MethodBasedEvaluationContext evaluationContext = null;
protected HangarDecisionVoter(Class<A> attributeClass) {
this.attributeClass = attributeClass;
this.parameterNameDiscoverer = new DefaultParameterNameDiscoverer();
}
public boolean isAllowMultipleAttributes() {
return allowMultipleAttributes;
}
public void setAllowMultipleAttributes(boolean allowMultipleAttributes) {
this.allowMultipleAttributes = allowMultipleAttributes;
}
@Override
public boolean supports(ConfigAttribute attribute) {
return attributeClass.isAssignableFrom(attribute.getClass());
@ -29,18 +51,43 @@ public abstract class HangarDecisionVoter<A extends ConfigAttribute> implements
return MethodInvocation.class.isAssignableFrom(clazz);
}
@SuppressWarnings("unchecked")
protected final A findAttribute(Collection<ConfigAttribute> attributes) {
for (ConfigAttribute attribute : attributes) {
if (attributeClass.isAssignableFrom(attribute.getClass())) {
return (A) attribute;
}
@Override
public final int vote(Authentication authentication, MethodInvocation methodInvocation, Collection<ConfigAttribute> configAttributes) {
Set<A> attributes = findAttributes(configAttributes);
if (attributes.isEmpty()) {
return ACCESS_ABSTAIN;
}
if (!allowMultipleAttributes && attributes.size() > 1) {
throw new IllegalArgumentException("Multiple " + attributeClass + " found where only 1 is allowed (or use setAllowMultipleAttributes(true))");
} else if (allowMultipleAttributes) {
return vote(authentication, methodInvocation, attributes);
} else {
return vote(authentication, methodInvocation, attributes.stream().findFirst().get());
}
return null;
}
protected final Collection<A> findAttributes(Collection<ConfigAttribute> attributes) {
public int vote(Authentication authentication, MethodInvocation methodInvocation, @NotNull A attribute) {
return ACCESS_ABSTAIN;
}
public int vote(Authentication authentication, MethodInvocation methodInvocation, Set<A> attributes) {
return ACCESS_ABSTAIN;
}
protected final Set<A> findAttributes(Collection<ConfigAttribute> attributes) {
return attributes.stream().filter(a -> attributeClass.isAssignableFrom(a.getClass())).map(attributeClass::cast).collect(Collectors.toSet());
}
@NotNull
protected final MethodBasedEvaluationContext getMethodEvaluationContext(MethodInvocation invocation) {
if (this.evaluationContext == null) {
this.evaluationContext = new MethodBasedEvaluationContext(
invocation.getMethod().getDeclaringClass(),
invocation.getMethod(),
invocation.getArguments(),
parameterNameDiscoverer
);
}
return this.evaluationContext;
}
}

View File

@ -7,14 +7,11 @@ import io.papermc.hangar.security.annotations.HangarDecisionVoter;
import io.papermc.hangar.security.annotations.currentuser.CurrentUserMetadataExtractor.CurrentUserAttribute;
import io.papermc.hangar.security.authentication.HangarAuthenticationToken;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Collection;
@Component
public class CurrentUserVoter extends HangarDecisionVoter<CurrentUserAttribute> {
@ -23,25 +20,16 @@ public class CurrentUserVoter extends HangarDecisionVoter<CurrentUserAttribute>
}
@Override
public int vote(Authentication authentication, MethodInvocation methodInvocation, Collection<ConfigAttribute> attributes) {
CurrentUserAttribute attribute = findAttribute(attributes);
if (attribute == null) {
return ACCESS_ABSTAIN;
}
public int vote(Authentication authentication, MethodInvocation methodInvocation, @NotNull CurrentUserAttribute attribute) {
if (!(authentication instanceof HangarAuthenticationToken)) {
throw new HangarApiException(HttpStatus.FORBIDDEN);
}
HangarAuthenticationToken hangarAuthenticationToken = (HangarAuthenticationToken) authentication;
if (hangarAuthenticationToken.getPrincipal().getGlobalPermissions().has(Permission.EditAllUserSettings)) {
if (hangarAuthenticationToken.getPrincipal().isAllowedGlobal(Permission.EditAllUserSettings)) {
return ACCESS_GRANTED;
}
String userName;
Object user = attribute.getExpression().getValue(new MethodBasedEvaluationContext(
methodInvocation.getMethod().getDeclaringClass(),
methodInvocation.getMethod(),
methodInvocation.getArguments(),
parameterNameDiscoverer
));
Object user = attribute.getExpression().getValue(getMethodEvaluationContext(methodInvocation));
if (user instanceof UserTable) {
userName = ((UserTable) user).getName();
} else if (user instanceof String) {

View File

@ -1,19 +1,20 @@
package io.papermc.hangar.security.annotations.permission;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.common.NamedPermission;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.security.annotations.HangarDecisionVoter;
import io.papermc.hangar.security.annotations.permission.PermissionRequiredMetadataExtractor.PermissionRequiredAttribute;
import io.papermc.hangar.security.authentication.HangarAuthenticationToken;
import io.papermc.hangar.service.PermissionService;
import org.aopalliance.intercept.MethodInvocation;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Arrays;
import java.util.Set;
@Component
public class PermissionRequiredVoter extends HangarDecisionVoter<PermissionRequiredAttribute> {
@ -24,55 +25,46 @@ public class PermissionRequiredVoter extends HangarDecisionVoter<PermissionRequi
public PermissionRequiredVoter(PermissionService permissionService) {
super(PermissionRequiredAttribute.class);
this.permissionService = permissionService;
this.setAllowMultipleAttributes(true);
}
@Override
public int vote(Authentication authentication, MethodInvocation methodInvocation, Collection<ConfigAttribute> attributes) {
Collection<PermissionRequiredAttribute> permAttributes = findAttributes(attributes);
if (permAttributes.isEmpty()) {
return ACCESS_ABSTAIN;
}
public int vote(Authentication authentication, MethodInvocation methodInvocation, Set<PermissionRequiredAttribute> attributes) {
if (!(authentication instanceof HangarAuthenticationToken)) {
throw new HangarApiException(HttpStatus.NOT_FOUND);
}
HangarAuthenticationToken hangarAuthenticationToken = (HangarAuthenticationToken) authentication;
int result = ACCESS_DENIED;
for (PermissionRequiredAttribute attribute : permAttributes) {
Object[] arguments = attribute.getExpression().getValue(new MethodBasedEvaluationContext(
methodInvocation.getMethod().getDeclaringClass(),
methodInvocation.getMethod(),
methodInvocation.getArguments(),
parameterNameDiscoverer
), Object[].class);
for (PermissionRequiredAttribute attribute : attributes) {
Object[] arguments = attribute.getExpression().getValue(getMethodEvaluationContext(methodInvocation), Object[].class);
if (arguments == null || !attribute.getPermissionType().getArgCounts().contains(arguments.length)) {
throw new IllegalStateException("Bad annotation configuration");
}
Permission requiredPerm = Arrays.stream(attribute.getPermissions()).map(NamedPermission::getPermission).reduce(Permission::add).orElse(Permission.None);
Permission currentPerm;
switch (attribute.getPermissionType()) {
case PROJECT:
if (arguments.length == 1 && permissionService.getProjectPermissions(hangarAuthenticationToken.getUserId(), (long) arguments[0]).hasAll(attribute.getPermissions())) {
result = ACCESS_GRANTED;
} else if (arguments.length == 2 && permissionService.getProjectPermissions(hangarAuthenticationToken.getUserId(), (String) arguments[0], (String) arguments[1]).hasAll(attribute.getPermissions())) {
result = ACCESS_GRANTED;
if (arguments.length == 1) {
currentPerm = permissionService.getProjectPermissions(hangarAuthenticationToken.getUserId(), (long) arguments[0]);
} else if (arguments.length == 2) {
currentPerm = permissionService.getProjectPermissions(hangarAuthenticationToken.getUserId(), (String) arguments[0], (String) arguments[1]);
} else {
result = ACCESS_DENIED;
currentPerm = Permission.None;
}
break;
case ORGANIZATION:
if (arguments.length == 1 && permissionService.getOrganizationPermissions(hangarAuthenticationToken.getUserId(), (String) arguments[0]).hasAll(attribute.getPermissions())) {
result = ACCESS_GRANTED;
if (arguments.length == 1) {
currentPerm = permissionService.getOrganizationPermissions(hangarAuthenticationToken.getUserId(), (String) arguments[0]);
} else {
result = ACCESS_DENIED;
currentPerm = Permission.None;
}
break;
case GLOBAL:
if (permissionService.getGlobalPermissions(hangarAuthenticationToken.getUserId()).hasAll(attribute.getPermissions())) {
result = ACCESS_GRANTED;
} else {
result = ACCESS_DENIED;
}
currentPerm = permissionService.getGlobalPermissions(hangarAuthenticationToken.getUserId());
break;
default:
currentPerm = Permission.None;
}
if (result == ACCESS_GRANTED) {
if (hangarAuthenticationToken.getPrincipal().isAllowed(requiredPerm, currentPerm)) {
return ACCESS_GRANTED;
}
}

View File

@ -1,36 +1,24 @@
package io.papermc.hangar.security.annotations.unlocked;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.security.annotations.HangarDecisionVoter;
import io.papermc.hangar.security.annotations.unlocked.UnlockedMetadataExtractor.UnlockedAttribute;
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.access.AccessDecisionVoter;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Collection;
@Component
public class UnlockedVoter implements AccessDecisionVoter<MethodInvocation> {
public class UnlockedVoter extends HangarDecisionVoter<UnlockedAttribute> {
@Override
public boolean supports(ConfigAttribute attribute) {
return attribute instanceof UnlockedAttribute;
public UnlockedVoter() {
super(UnlockedAttribute.class);
}
@Override
public boolean supports(Class<?> clazz) {
return MethodInvocation.class.isAssignableFrom(clazz);
}
@Override
public int vote(Authentication authentication, MethodInvocation object, Collection<ConfigAttribute> attributes) {
UnlockedAttribute attribute = findUnlockedAttribute(attributes);
if (attribute == null) {
return ACCESS_ABSTAIN;
}
public int vote(Authentication authentication, MethodInvocation object, @NotNull UnlockedAttribute attribute) {
if (!(authentication instanceof HangarAuthenticationToken)) {
return ACCESS_DENIED;
}
@ -39,13 +27,4 @@ public class UnlockedVoter implements AccessDecisionVoter<MethodInvocation> {
}
return ACCESS_GRANTED;
}
private UnlockedAttribute findUnlockedAttribute(Collection<ConfigAttribute> attributes) {
for (ConfigAttribute attribute : attributes) {
if (attribute instanceof UnlockedAttribute) {
return (UnlockedAttribute) attribute;
}
}
return null;
}
}

View File

@ -7,15 +7,12 @@ import io.papermc.hangar.security.annotations.visibility.VisibilityRequiredMetad
import io.papermc.hangar.service.internal.projects.ProjectService;
import io.papermc.hangar.service.internal.versions.VersionService;
import org.aopalliance.intercept.MethodInvocation;
import org.jetbrains.annotations.NotNull;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.expression.MethodBasedEvaluationContext;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.ConfigAttribute;
import org.springframework.security.core.Authentication;
import org.springframework.stereotype.Component;
import java.util.Collection;
@Component
public class VisibilityRequiredVoter extends HangarDecisionVoter<VisibilityRequiredAttribute> {
@ -30,17 +27,8 @@ public class VisibilityRequiredVoter extends HangarDecisionVoter<VisibilityRequi
}
@Override
public int vote(Authentication authentication, MethodInvocation method, Collection<ConfigAttribute> attributes) {
VisibilityRequiredAttribute attribute = findAttribute(attributes);
if (attribute == null) {
return ACCESS_ABSTAIN;
}
Object[] arguments = attribute.getExpression().getValue(new MethodBasedEvaluationContext(
method.getMethod().getDeclaringClass(),
method.getMethod(),
method.getArguments(),
parameterNameDiscoverer
), Object[].class);
public int vote(Authentication authentication, MethodInvocation method, @NotNull VisibilityRequiredAttribute attribute) {
Object[] arguments = attribute.getExpression().getValue(getMethodEvaluationContext(method), Object[].class);
if (arguments == null || !attribute.getType().getArgCount().contains(arguments.length)) {
throw new IllegalStateException("Bad annotation configuration");
}

View File

@ -1,20 +1,44 @@
package io.papermc.hangar.security.authentication;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.papermc.hangar.exceptions.HangarApiException;
import org.apache.pdfbox.util.Charsets;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
* For both INTERNAL and API requests
*/
@Component
public class HangarAuthenticationEntryPoint implements AuthenticationEntryPoint {
private final ObjectMapper mapper;
@Autowired
public HangarAuthenticationEntryPoint(ObjectMapper mapper) {
this.mapper = mapper;
}
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException e) throws IOException {
// All should-be-authenticated requests are NOT browser requests, so redirecting from here to login won't work.
response.sendError(HttpServletResponse.SC_NOT_FOUND, "Not found");
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException {
HttpStatus status;
if (failed instanceof CredentialsExpiredException) {
status = HttpStatus.FORBIDDEN;
} else if (failed instanceof BadCredentialsException) {
status = HttpStatus.UNAUTHORIZED;
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.setStatus(status.value());
response.setCharacterEncoding(Charsets.UTF_8.name());
response.getWriter().write(mapper.writeValueAsString(new HangarApiException(status, failed.getMessage())));
}
}

View File

@ -2,12 +2,10 @@ package io.papermc.hangar.security.authentication;
import com.auth0.jwt.exceptions.JWTVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.security.configs.SecurityConfig;
import io.papermc.hangar.service.TokenService;
import org.springframework.core.log.LogMessage;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.CredentialsExpiredException;
@ -15,7 +13,9 @@ import org.springframework.security.authentication.InternalAuthenticationService
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.authentication.AuthenticationEntryPointFailureHandler;
import org.springframework.security.web.util.matcher.RequestMatcher;
import javax.servlet.FilterChain;
@ -34,9 +34,10 @@ public class HangarAuthenticationFilter extends AbstractAuthenticationProcessing
private final TokenService tokenService;
public HangarAuthenticationFilter(final RequestMatcher requiresAuth, final TokenService tokenService, final AuthenticationManager authenticationManager) {
public HangarAuthenticationFilter(final RequestMatcher requiresAuth, final TokenService tokenService, final AuthenticationManager authenticationManager, final AuthenticationEntryPoint authenticationEntryPoint) {
super(requiresAuth);
this.setAuthenticationManager(authenticationManager);
this.setAuthenticationFailureHandler(new AuthenticationEntryPointFailureHandler(authenticationEntryPoint));
this.tokenService = tokenService;
}
@ -65,7 +66,7 @@ public class HangarAuthenticationFilter extends AbstractAuthenticationProcessing
// request should ALWAYS have a `HangarAuthJWTToken` attribute here
String jwt = (String) request.getAttribute(AUTH_TOKEN_ATTR);
try {
HangarAuthenticationToken token = new HangarAuthenticationToken(tokenService.verify(jwt));
HangarAuthenticationToken token = HangarAuthenticationToken.createUnverifiedToken(tokenService.verify(jwt));
return getAuthenticationManager().authenticate(token);
} catch (TokenExpiredException tokenExpiredException) {
throw new CredentialsExpiredException("JWT was expired", tokenExpiredException);
@ -84,24 +85,4 @@ public class HangarAuthenticationFilter extends AbstractAuthenticationProcessing
}
chain.doFilter(request, response);
}
@Override
protected void unsuccessfulAuthentication(HttpServletRequest request, HttpServletResponse response, AuthenticationException failed) throws IOException, ServletException {
SecurityContextHolder.clearContext();
if (this.logger.isTraceEnabled()) {
this.logger.trace("Failed to process authentication request", failed);
this.logger.trace("Cleared SecurityContextHolder");
this.logger.trace("Handling authentication failure");
}
HttpStatus status;
if (failed instanceof CredentialsExpiredException) {
status = HttpStatus.FORBIDDEN;
} else if (failed instanceof BadCredentialsException) {
status = HttpStatus.UNAUTHORIZED;
} else {
status = HttpStatus.INTERNAL_SERVER_ERROR;
}
throw new HangarApiException(status, failed.getMessage());
}
}

View File

@ -22,7 +22,7 @@ public class HangarAuthenticationProvider implements AuthenticationProvider {
HangarAuthenticationToken token = (HangarAuthenticationToken) authentication;
HangarPrincipal hangarPrincipal = tokenService.parseHangarPrincipal(token.getCredentials());
return new HangarAuthenticationToken(hangarPrincipal, token.getCredentials());
return HangarAuthenticationToken.createVerifiedToken(hangarPrincipal, token.getCredentials());
}
@Override

View File

@ -15,21 +15,29 @@ public class HangarAuthenticationToken extends AbstractAuthenticationToken {
private final DecodedJWT token;
private final HangarPrincipal user;
// Used by HangarAuthenticationProvider once user is verified
public HangarAuthenticationToken(HangarPrincipal user, DecodedJWT token) {
private HangarAuthenticationToken(HangarPrincipal user, DecodedJWT token) {
super(AuthorityUtils.createAuthorityList("ROLE_USER"));
this.token = token;
this.user = user;
super.setAuthenticated(true);
}
// Initial token creation before verifying the user exists in the table
public HangarAuthenticationToken(DecodedJWT token) {
private HangarAuthenticationToken(DecodedJWT token) {
super(null);
this.token = token;
this.user = null;
}
// Initial token creation before verifying the user exists in the table
public static HangarAuthenticationToken createUnverifiedToken(DecodedJWT token) {
return new HangarAuthenticationToken(token);
}
// Used by HangarAuthenticationProvider once user is verified
public static HangarAuthenticationToken createVerifiedToken(HangarPrincipal user, DecodedJWT token) {
return new HangarAuthenticationToken(user, token);
}
@Override
public DecodedJWT getCredentials() {
return token;

View File

@ -32,12 +32,21 @@ public class HangarPrincipal implements ProjectOwner {
return id;
}
public boolean isLocked() {
public final boolean isLocked() {
return locked;
}
public Permission getGlobalPermissions() {
return globalPermissions;
public Permission getPossiblePermissions(){ return Permission.All; }
public final Permission getGlobalPermissions() {
return globalPermissions.intersect(getPossiblePermissions());
}
public final boolean isAllowedGlobal(Permission requiredPermission) {
return isAllowed(requiredPermission, globalPermissions);
}
public final boolean isAllowed(Permission requiredPermission, Permission currentPermission) {
return getPossiblePermissions().has(requiredPermission.intersect(currentPermission));
}
@Override

View File

@ -0,0 +1,31 @@
package io.papermc.hangar.security.authentication.api;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.db.auth.ApiKeyTable;
import io.papermc.hangar.security.authentication.HangarPrincipal;
public class HangarApiPrincipal extends HangarPrincipal {
private final ApiKeyTable apiKeyTable;
public HangarApiPrincipal(long id, String name, boolean locked, Permission globalPermissions, ApiKeyTable apiKeyTable) {
super(id, name, locked, globalPermissions);
this.apiKeyTable = apiKeyTable;
}
@Override
public Permission getPossiblePermissions() {
return apiKeyTable.getPermissions();
}
public ApiKeyTable getApiKeyTable() {
return apiKeyTable;
}
@Override
public String toString() {
return "HangarApiPrincipal{" +
"apiKeyTable=" + apiKeyTable +
"} " + super.toString();
}
}

View File

@ -1,6 +1,5 @@
package io.papermc.hangar.security.configs;
import io.papermc.hangar.security.authentication.HangarAuthenticationEntryPoint;
import io.papermc.hangar.security.authentication.HangarAuthenticationFilter;
import io.papermc.hangar.security.authentication.HangarAuthenticationProvider;
import io.papermc.hangar.service.TokenService;
@ -12,6 +11,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.util.matcher.AndRequestMatcher;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
@ -30,10 +30,12 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
private static final RequestMatcher INTERNAL_API_MATCHER = new AntPathRequestMatcher("/api/internal/**");
private final TokenService tokenService;
private final AuthenticationEntryPoint authenticationEntryPoint;
@Autowired
public SecurityConfig(TokenService tokenService, HangarAuthenticationProvider hangarAuthenticationProvider) {
public SecurityConfig(TokenService tokenService, HangarAuthenticationProvider hangarAuthenticationProvider, AuthenticationEntryPoint authenticationEntryPoint) {
this.tokenService = tokenService;
this.authenticationEntryPoint = authenticationEntryPoint;
}
@Override
@ -41,8 +43,8 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
http
// Disable default configurations
.logout().disable()
// .httpBasic().disable()
// .formLogin().disable()
.httpBasic().disable()
.formLogin().disable()
// Disable session creation
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
@ -51,9 +53,9 @@ public class SecurityConfig extends WebSecurityConfigurerAdapter {
.csrf().disable()
// Custom auth filters
.addFilterBefore(new HangarAuthenticationFilter(API_MATCHER, tokenService, authenticationManager()), AnonymousAuthenticationFilter.class)
.addFilterBefore(new HangarAuthenticationFilter(API_MATCHER, tokenService, authenticationManager(), authenticationEntryPoint), AnonymousAuthenticationFilter.class)
.exceptionHandling().authenticationEntryPoint(new HangarAuthenticationEntryPoint()).and()
// .exceptionHandling().authenticationEntryPoint(authenticationEntryPoint).and()
// Permit all (use method security for controller access)
.authorizeRequests().anyRequest().permitAll();

View File

@ -5,15 +5,19 @@ import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.interfaces.DecodedJWT;
import io.papermc.hangar.db.dao.HangarDao;
import io.papermc.hangar.db.dao.internal.table.auth.ApiKeyDAO;
import io.papermc.hangar.db.dao.internal.table.auth.UserRefreshTokenDAO;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.api.auth.RefreshResponse;
import io.papermc.hangar.model.common.Permission;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.db.auth.ApiKeyTable;
import io.papermc.hangar.model.db.auth.UserRefreshToken;
import io.papermc.hangar.security.authentication.HangarPrincipal;
import io.papermc.hangar.security.authentication.api.HangarApiPrincipal;
import io.papermc.hangar.security.configs.SecurityConfig;
import io.papermc.hangar.service.internal.users.UserService;
import org.jetbrains.annotations.Nullable;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
@ -29,6 +33,7 @@ import java.util.UUID;
@Service
public class TokenService extends HangarService {
private final ApiKeyDAO apiKeyDAO;
private final UserRefreshTokenDAO userRefreshTokenDAO;
private final UserService userService;
private final PermissionService permissionService;
@ -37,7 +42,8 @@ public class TokenService extends HangarService {
private Algorithm algo;
@Autowired
public TokenService(HangarDao<UserRefreshTokenDAO> userRefreshTokenDAO, UserService userService, PermissionService permissionService) {
public TokenService(HangarDao<ApiKeyDAO> apiKeyDAO, HangarDao<UserRefreshTokenDAO> userRefreshTokenDAO, UserService userService, PermissionService permissionService) {
this.apiKeyDAO = apiKeyDAO.get();
this.userRefreshTokenDAO = userRefreshTokenDAO.get();
this.userService = userService;
this.permissionService = permissionService;
@ -55,7 +61,7 @@ public class TokenService extends HangarService {
private String _newToken(UserTable userTable, UserRefreshToken userRefreshToken) {
Permission globalPermissions = permissionService.getGlobalPermissions(userTable.getId());
return expiring(userTable, globalPermissions);
return expiring(userTable, globalPermissions, null);
}
public RefreshResponse refreshToken(String refreshToken) {
@ -81,7 +87,7 @@ public class TokenService extends HangarService {
userRefreshTokenDAO.delete(UUID.fromString(refreshToken));
}
public String expiring(UserTable userTable, Permission globalPermission) {
public String expiring(UserTable userTable, Permission globalPermission, @Nullable String apiKeyIdentifier) {
return JWT.create()
.withIssuer(config.security.getTokenIssuer())
.withExpiresAt(new Date(Instant.now().plus(config.security.getTokenExpiry()).toEpochMilli()))
@ -89,6 +95,7 @@ public class TokenService extends HangarService {
.withClaim("id", userTable.getId())
.withClaim("permissions", globalPermission.toBinString())
.withClaim("locked", userTable.isLocked())
.withClaim("apiKeyIdentifier", apiKeyIdentifier)
.sign(getAlgo());
}
@ -97,10 +104,19 @@ public class TokenService extends HangarService {
Long userId = decodedJWT.getClaim("id").asLong();
boolean locked = decodedJWT.getClaim("locked").asBoolean();
Permission globalPermission = Permission.fromBinString(decodedJWT.getClaim("permissions").asString());
String apiKeyIdentifier = decodedJWT.getClaim("apiKeyIdentifier").asString();
if (subject == null || userId == null || globalPermission == null) {
throw new BadCredentialsException("Malformed jwt");
}
return new HangarPrincipal(userId, subject, locked, globalPermission);
if (apiKeyIdentifier != null) {
ApiKeyTable apiKeyTable = apiKeyDAO.findApiKey(userId, apiKeyIdentifier);
if (apiKeyTable == null) {
throw new BadCredentialsException("Invalid api key identifier");
}
return new HangarApiPrincipal(userId, subject, locked, globalPermission, apiKeyTable);
} else {
return new HangarPrincipal(userId, subject, locked, globalPermission);
}
}
private JWTVerifier getVerifier() {

View File

@ -0,0 +1,52 @@
package io.papermc.hangar.service.api;
import io.papermc.hangar.db.dao.HangarDao;
import io.papermc.hangar.db.dao.internal.table.UserDAO;
import io.papermc.hangar.db.dao.internal.table.auth.ApiKeyDAO;
import io.papermc.hangar.exceptions.HangarApiException;
import io.papermc.hangar.model.api.auth.ApiSession;
import io.papermc.hangar.model.db.UserTable;
import io.papermc.hangar.model.db.auth.ApiKeyTable;
import io.papermc.hangar.service.HangarService;
import io.papermc.hangar.service.PermissionService;
import io.papermc.hangar.service.TokenService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
@Service
public class APIAuthenticationService extends HangarService {
private static final String UUID_REGEX = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
private static final Pattern API_KEY_PATTERN = Pattern.compile("(" + UUID_REGEX + ").(" + UUID_REGEX + ")");
private final UserDAO userDAO;
private final ApiKeyDAO apiKeyDAO;
private final TokenService tokenService;
private final PermissionService permissionService;
@Autowired
public APIAuthenticationService(HangarDao<UserDAO> userDAO, HangarDao<ApiKeyDAO> apiKeyDAO, TokenService tokenService, PermissionService permissionService) {
this.userDAO = userDAO.get();
this.apiKeyDAO = apiKeyDAO.get();
this.tokenService = tokenService;
this.permissionService = permissionService;
}
public ApiSession createJWTForApiKey(String apiKey) {
if (!API_KEY_PATTERN.matcher(apiKey).matches()) {
throw new HangarApiException("Badly formatted API Key");
}
String identifier = apiKey.split("\\.")[0];
String token = apiKey.split("\\.")[1];
ApiKeyTable apiKeyTable = apiKeyDAO.findApiKey(identifier, token);
if (apiKeyTable == null) {
throw new HangarApiException("No valid API Key found");
}
UserTable userTable = userDAO.getUserTable(apiKeyTable.getOwnerId());
String jwt = tokenService.expiring(userTable, permissionService.getGlobalPermissions(userTable.getId()), identifier);
return new ApiSession(jwt, config.security.getRefreshTokenExpiry().toSeconds());
}
// 006ad884-3df9-43e8-af01-91590f92cfd7.fa31831d-097f-4d11-9031-b57b41c59fa1
}

View File

@ -35,7 +35,7 @@ public class ProjectsApiService extends HangarService {
}
public PaginatedResult<ProjectMember> getProjectMembers(String author, String slug, RequestPagination requestPagination) {
List<ProjectMember> projectMembers = projectsApiDAO.getProjectMembers(author, slug, requestPagination.getLimit(), requestPagination.getOffset());
List<ProjectMember> projectMembers = projectsApiDAO.getProjectMembers(author, slug, requestPagination);
return new PaginatedResult<>(new Pagination(projectsApiDAO.getProjectMembersCount(author, slug), requestPagination), projectMembers);
}

View File

@ -28,8 +28,6 @@ public class AuthenticationService extends HangarService {
private final HttpServletRequest request;
private static final Pattern API_ROUTE_PATTERN = Pattern.compile("^/api/(?!old).+");
private static final String UUID_REGEX = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}";
private static final Pattern API_KEY_PATTERN = Pattern.compile("(" + UUID_REGEX + ").(" + UUID_REGEX + ")");
@Autowired
public AuthenticationService(HangarDao<SessionsDao> sessionsDao, HangarDao<ApiKeyDao> apiKeyDao, HttpServletRequest request, Supplier<Optional<UsersTable>> currentUser) {

View File

@ -0,0 +1,3 @@
ALTER TABLE api_keys ADD CONSTRAINT api_keys_owner_id_token_identifier UNIQUE (owner_id, token_identifier);
DROP TABLE api_sessions;