From 0c283fb3148f97043c6b76f6c2ff2cf2fe632c1b Mon Sep 17 00:00:00 2001 From: orsond Date: Sun, 24 Oct 2021 19:12:41 +1100 Subject: [PATCH 1/3] NEW: group tokens --- .../com/collarmc/server/CollarServer.java | 1 - .../java/com/collarmc/server/Services.java | 4 +- .../java/com/collarmc/server/WebServer.java | 13 +- .../server/configuration/Configuration.java | 9 +- .../com/collarmc/server/http/ApiToken.java | 91 ------------- .../java/com/collarmc/server/http/Cookie.java | 72 ---------- .../TokenCrypterImpl.java} | 9 +- .../ServerAuthenticationService.java | 6 +- .../authentication/VerificationToken.java | 1 + .../server/services/groups/GroupService.java | 37 +++++- .../server/services/groups/GroupStore.java | 41 +++++- .../collarmc/server/http/ApiTokenTest.java | 13 +- .../services/groups/GroupStoreTest.java | 5 +- .../java/com/collarmc/api/groups/Group.java | 27 +++- .../http/UpdateGroupMembershipRequest.java | 24 ++++ .../http/UpdateGroupMembershipResponse.java | 13 ++ .../com/collarmc/api/http/HttpException.java | 5 + .../com/collarmc/api/http/RequestContext.java | 13 +- .../java/com/collarmc/security/ApiToken.java | 123 ++++++++++++++++++ .../com/collarmc/security/TokenCrypter.java | 8 ++ .../collarmc/tests/textures/TexturesTest.java | 5 +- 21 files changed, 315 insertions(+), 205 deletions(-) delete mode 100644 server/src/main/java/com/collarmc/server/http/ApiToken.java delete mode 100644 server/src/main/java/com/collarmc/server/http/Cookie.java rename server/src/main/java/com/collarmc/server/{services/authentication/TokenCrypter.java => security/TokenCrypterImpl.java} (64%) create mode 100644 shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipRequest.java create mode 100644 shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipResponse.java create mode 100644 shared/src/main/java/com/collarmc/security/ApiToken.java create mode 100644 shared/src/main/java/com/collarmc/security/TokenCrypter.java diff --git a/server/src/main/java/com/collarmc/server/CollarServer.java b/server/src/main/java/com/collarmc/server/CollarServer.java index cff31de7..e2474f32 100644 --- a/server/src/main/java/com/collarmc/server/CollarServer.java +++ b/server/src/main/java/com/collarmc/server/CollarServer.java @@ -24,7 +24,6 @@ import com.collarmc.security.messages.CipherException; import com.collarmc.security.mojang.MinecraftPlayer; import com.collarmc.security.mojang.Mojang; -import com.collarmc.server.http.ApiToken; import com.collarmc.server.protocol.*; import io.github.bucket4j.Bandwidth; import io.github.bucket4j.Bucket; diff --git a/server/src/main/java/com/collarmc/server/Services.java b/server/src/main/java/com/collarmc/server/Services.java index 98f673cd..6fa3323c 100644 --- a/server/src/main/java/com/collarmc/server/Services.java +++ b/server/src/main/java/com/collarmc/server/Services.java @@ -9,7 +9,7 @@ import com.collarmc.server.security.hashing.PasswordHashing; import com.collarmc.server.security.mojang.MinecraftSessionVerifier; import com.collarmc.server.services.authentication.ServerAuthenticationService; -import com.collarmc.server.services.authentication.TokenCrypter; +import com.collarmc.security.TokenCrypter; import com.collarmc.server.services.friends.FriendsService; import com.collarmc.server.services.groups.GroupService; import com.collarmc.server.services.groups.GroupStore; @@ -61,7 +61,7 @@ public Services(Configuration configuration) throws Exception { this.tokenCrypter = configuration.tokenCrypter; this.auth = new ServerAuthenticationService(profiles, passwordHashing, tokenCrypter, configuration.email, urlProvider); this.minecraftSessionVerifier = configuration.minecraftSessionVerifier; - this.groupStore = new GroupStore(profileCache, sessions, configuration.database); + this.groupStore = new GroupStore(profileCache, sessions, tokenCrypter, configuration.database); this.groups = new GroupService(groupStore, profileCache, sessions, identityStore.cipher()); this.playerLocations = new PlayerLocationService(this); this.textures = new TextureService(configuration.database); diff --git a/server/src/main/java/com/collarmc/server/WebServer.java b/server/src/main/java/com/collarmc/server/WebServer.java index 4641e135..aebbff08 100644 --- a/server/src/main/java/com/collarmc/server/WebServer.java +++ b/server/src/main/java/com/collarmc/server/WebServer.java @@ -5,6 +5,7 @@ import com.collarmc.api.groups.GroupType; import com.collarmc.api.groups.MembershipRole; import com.collarmc.api.groups.http.CreateGroupTokenRequest; +import com.collarmc.api.groups.http.UpdateGroupMembershipRequest; import com.collarmc.api.http.*; import com.collarmc.api.http.HttpException.BadRequestException; import com.collarmc.api.http.HttpException.NotFoundException; @@ -18,9 +19,9 @@ import com.collarmc.server.common.ServerStatus; import com.collarmc.server.common.ServerVersion; import com.collarmc.server.configuration.Configuration; -import com.collarmc.server.http.ApiToken; +import com.collarmc.security.ApiToken; import com.collarmc.server.http.HandlebarsTemplateEngine; -import com.collarmc.server.services.authentication.TokenCrypter; +import com.collarmc.security.TokenCrypter; import com.collarmc.api.groups.http.ValidateGroupTokenRequest; import com.collarmc.server.services.textures.TextureService; import com.collarmc.server.session.ClientRegistrationService; @@ -210,7 +211,7 @@ public void start(Consumer callback) throws Exception { }); path("/groups", () -> { - get("/groups", (request, response) -> { + get("/", (request, response) -> { RequestContext context = from(request); context.assertNotAnonymous(); return services.groupStore.findGroupsContaining(context.owner).collect(Collectors.toList()); @@ -228,6 +229,12 @@ public void start(Consumer callback) throws Exception { CreateGroupTokenRequest req = services.jsonMapper.readValue(request.bodyAsBytes(), CreateGroupTokenRequest.class); return services.groups.createGroupToken(context, req); }, services.jsonMapper::writeValueAsString); + post("/members/add", (request, response) -> { + RequestContext context = from(request); + context.assertNotAnonymous(); + UpdateGroupMembershipRequest req = services.jsonMapper.readValue(request.bodyAsBytes(), UpdateGroupMembershipRequest.class); + return services.groups.updateMembers(context, req); + }, services.jsonMapper::writeValueAsString); }); path("/auth", () -> { diff --git a/server/src/main/java/com/collarmc/server/configuration/Configuration.java b/server/src/main/java/com/collarmc/server/configuration/Configuration.java index 2de8de08..a4f6926b 100644 --- a/server/src/main/java/com/collarmc/server/configuration/Configuration.java +++ b/server/src/main/java/com/collarmc/server/configuration/Configuration.java @@ -8,11 +8,12 @@ import com.collarmc.server.mail.LocalEmail; import com.collarmc.server.mail.MailGunEmail; import com.collarmc.server.mongo.Mongo; +import com.collarmc.server.security.TokenCrypterImpl; import com.collarmc.server.security.hashing.PasswordHashing; import com.collarmc.server.security.mojang.MinecraftSessionVerifier; import com.collarmc.server.security.mojang.MojangMinecraftSessionVerifier; import com.collarmc.server.security.mojang.NojangMinecraftSessionVerifier; -import com.collarmc.server.services.authentication.TokenCrypter; +import com.collarmc.security.TokenCrypter; import com.mongodb.client.MongoDatabase; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -87,7 +88,7 @@ public static Configuration fromEnvironment() { return new Configuration( Mongo.database(), appUrlProvider, - new TokenCrypter(crypterPassword), + new TokenCrypterImpl(crypterPassword), new PasswordHashing(passwordSalt), useMojang ? new MojangMinecraftSessionVerifier(http) : new NojangMinecraftSessionVerifier(), appUrlProvider.homeUrl(), @@ -104,7 +105,7 @@ public static Configuration defaultConfiguration() { return new Configuration( Mongo.database("mongodb://localhost/collar-dev"), appUrlProvider, - new TokenCrypter("insecureTokenCrypterPassword"), + new TokenCrypterImpl("insecureTokenCrypterPassword"), new PasswordHashing("VSZL*bR8-=r]r5P_"), new NojangMinecraftSessionVerifier(), "*", @@ -120,7 +121,7 @@ public static Configuration testConfiguration(MongoDatabase db, MinecraftSession return new Configuration( db, appUrlProvider, - new TokenCrypter("insecureTokenCrypterPassword"), + new TokenCrypterImpl("insecureTokenCrypterPassword"), new PasswordHashing("VSZL*bR8-=r]r5P_"), sessionVerifier, "*", diff --git a/server/src/main/java/com/collarmc/server/http/ApiToken.java b/server/src/main/java/com/collarmc/server/http/ApiToken.java deleted file mode 100644 index dfe1da2a..00000000 --- a/server/src/main/java/com/collarmc/server/http/ApiToken.java +++ /dev/null @@ -1,91 +0,0 @@ -package com.collarmc.server.http; - -import com.collarmc.api.http.RequestContext; -import com.collarmc.api.profiles.Role; -import com.collarmc.server.services.authentication.TokenCrypter; -import com.google.common.io.BaseEncoding; - -import java.io.*; -import java.util.Date; -import java.util.HashSet; -import java.util.Set; -import java.util.UUID; -import java.util.concurrent.TimeUnit; - -/** - * Used for authorizing API calls - */ -public class ApiToken { - - private static final int VERSION = 2; - - public final UUID profileId; - public final long expiresAt; - public final Set roles; - - public ApiToken(UUID profileId, long expiresAt, Set roles) { - this.profileId = profileId; - this.expiresAt = expiresAt; - this.roles = roles; - } - - public ApiToken(UUID profileId, Set roles) { - this.profileId = profileId; - this.roles = roles; - this.expiresAt = new Date().getTime() + TimeUnit.DAYS.toMillis(7); - } - - public boolean isExpired() { - return new Date().after(new Date(expiresAt)); - } - - public String serialize(TokenCrypter crypter) throws IOException { - try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { - try (DataOutputStream dataStream = new DataOutputStream(outputStream)) { - dataStream.write(VERSION); - dataStream.writeUTF(profileId.toString()); - dataStream.writeLong(expiresAt); - dataStream.writeInt(roles.size()); - for (Role role : roles) { - dataStream.writeInt(role.ordinal()); - } - } - return BaseEncoding.base64Url().encode(crypter.crypt(outputStream.toByteArray())); - } - } - - public static ApiToken deserialize(TokenCrypter crypter, String token) throws IOException { - byte[] bytes = BaseEncoding.base64Url().decode(token); - byte[] decryptedBytes = crypter.decrypt(bytes); - try (ByteArrayInputStream inputStream = new ByteArrayInputStream(decryptedBytes)) { - try (DataInputStream dataStream = new DataInputStream(inputStream)) { - int version = dataStream.read(); - String uuidAsString; - long expiresAt; - Set roles = new HashSet<>(); - switch (version) { - case 1 -> { - uuidAsString = dataStream.readUTF(); - expiresAt = dataStream.readLong(); - roles.add(Role.PLAYER); - } - case 2 -> { - uuidAsString = dataStream.readUTF(); - expiresAt = dataStream.readLong(); - int rolesCount = dataStream.readInt(); - for (int i = 0; i < rolesCount; i++) { - int roleId = dataStream.readInt(); - roles.add(Role.values()[roleId]); - } - } - default -> throw new IllegalStateException("unknown version " + version); - } - return new ApiToken(UUID.fromString(uuidAsString), expiresAt, roles); - } - } - } - - public RequestContext fromToken() { - return new RequestContext(profileId, roles); - } -} diff --git a/server/src/main/java/com/collarmc/server/http/Cookie.java b/server/src/main/java/com/collarmc/server/http/Cookie.java deleted file mode 100644 index c81b9cbc..00000000 --- a/server/src/main/java/com/collarmc/server/http/Cookie.java +++ /dev/null @@ -1,72 +0,0 @@ -package com.collarmc.server.http; - -import com.collarmc.server.services.authentication.TokenCrypter; -import com.google.common.io.BaseEncoding; -import spark.Request; -import spark.Response; - -import java.io.*; -import java.util.Date; -import java.util.UUID; - -public class Cookie { - - private static final int VERSION = 1; - - public final UUID profileId; - public final long expiresAt; - - public Cookie(UUID profileId, long expiresAt) { - this.profileId = profileId; - this.expiresAt = expiresAt; - } - - public static void remove(Response response) { - response.cookie("identity", null); - } - - public static Cookie from(TokenCrypter crypter, Request request) { - String identity = request.cookie("identity"); - if (identity == null) { - return null; - } - byte[] bytes; { - try { - bytes = crypter.decrypt(BaseEncoding.base64Url().decode(identity)); - } catch (Throwable e) { - return null; - } - } - try (ByteArrayInputStream byteStream = new ByteArrayInputStream(bytes)) { - try (DataInputStream objectStream = new DataInputStream(byteStream)) { - String profileId; - long expiresAt; - int version = objectStream.readInt(); - switch (version) { - case 1: - profileId = objectStream.readUTF(); - expiresAt = objectStream.readLong(); - break; - default: - throw new IllegalStateException("unsupported version " + version); - } - return new Date().after(new Date(expiresAt)) ? null : new Cookie(UUID.fromString(profileId), expiresAt); - } - } catch (IOException e) { - return null; - } - } - - public void set(TokenCrypter crypter, Response response) throws IOException { - try (ByteArrayOutputStream byteStream = new ByteArrayOutputStream()) { - try (DataOutputStream objectStream = new DataOutputStream(byteStream)) { - objectStream.writeInt(VERSION); - objectStream.writeUTF(profileId.toString()); - objectStream.writeLong(expiresAt); - } - byteStream.flush(); - String encoded = BaseEncoding.base64Url().encode(crypter.crypt(byteStream.toByteArray())); - response.cookie("identity", encoded); - } - } -} diff --git a/server/src/main/java/com/collarmc/server/services/authentication/TokenCrypter.java b/server/src/main/java/com/collarmc/server/security/TokenCrypterImpl.java similarity index 64% rename from server/src/main/java/com/collarmc/server/services/authentication/TokenCrypter.java rename to server/src/main/java/com/collarmc/server/security/TokenCrypterImpl.java index 4797993c..dde52bfa 100644 --- a/server/src/main/java/com/collarmc/server/services/authentication/TokenCrypter.java +++ b/server/src/main/java/com/collarmc/server/security/TokenCrypterImpl.java @@ -1,21 +1,24 @@ -package com.collarmc.server.services.authentication; +package com.collarmc.server.security; +import com.collarmc.security.TokenCrypter; import org.jasypt.util.binary.AES256BinaryEncryptor; -public class TokenCrypter { +public class TokenCrypterImpl implements TokenCrypter { private final AES256BinaryEncryptor encryptor; - public TokenCrypter(String password) { + public TokenCrypterImpl(String password) { encryptor = new AES256BinaryEncryptor(); encryptor.setPassword(password); } + @Override public byte[] decrypt(byte[] bytes) { return encryptor.decrypt(bytes); } + @Override public byte[] crypt(byte[] bytes) { return encryptor.encrypt(bytes); } diff --git a/server/src/main/java/com/collarmc/server/services/authentication/ServerAuthenticationService.java b/server/src/main/java/com/collarmc/server/services/authentication/ServerAuthenticationService.java index e64c3e05..1a7d17e0 100644 --- a/server/src/main/java/com/collarmc/server/services/authentication/ServerAuthenticationService.java +++ b/server/src/main/java/com/collarmc/server/services/authentication/ServerAuthenticationService.java @@ -9,9 +9,10 @@ import com.collarmc.api.profiles.ProfileService.CreateProfileRequest; import com.collarmc.api.profiles.ProfileService.GetProfileRequest; import com.collarmc.api.profiles.ProfileService.UpdateProfileRequest; -import com.collarmc.server.http.ApiToken; +import com.collarmc.security.ApiToken; import com.collarmc.server.http.AppUrlProvider; import com.collarmc.server.mail.Email; +import com.collarmc.security.TokenCrypter; import com.collarmc.server.security.hashing.PasswordHashing; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -20,6 +21,7 @@ import java.util.Date; import java.util.Map; import java.util.Objects; +import java.util.Set; import java.util.concurrent.ForkJoinPool; import java.util.concurrent.TimeUnit; @@ -161,7 +163,7 @@ public ResetPasswordResponse resetPassword(RequestContext context, ResetPassword * @return token */ public String createToken(Profile profile) { - ApiToken apiToken = new ApiToken(profile.id, profile.roles); + ApiToken apiToken = new ApiToken(profile.id, profile.roles, Set.of()); String token; try { token = apiToken.serialize(tokenCrypter); diff --git a/server/src/main/java/com/collarmc/server/services/authentication/VerificationToken.java b/server/src/main/java/com/collarmc/server/services/authentication/VerificationToken.java index 082a9958..e9a4d5f3 100644 --- a/server/src/main/java/com/collarmc/server/services/authentication/VerificationToken.java +++ b/server/src/main/java/com/collarmc/server/services/authentication/VerificationToken.java @@ -1,5 +1,6 @@ package com.collarmc.server.services.authentication; +import com.collarmc.security.TokenCrypter; import com.google.common.io.BaseEncoding; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; diff --git a/server/src/main/java/com/collarmc/server/services/groups/GroupService.java b/server/src/main/java/com/collarmc/server/services/groups/GroupService.java index 6d39eaed..969f8a41 100644 --- a/server/src/main/java/com/collarmc/server/services/groups/GroupService.java +++ b/server/src/main/java/com/collarmc/server/services/groups/GroupService.java @@ -2,9 +2,7 @@ import com.collarmc.api.friends.Status; import com.collarmc.api.groups.*; -import com.collarmc.api.groups.http.CreateGroupTokenRequest; -import com.collarmc.api.groups.http.CreateGroupTokenResponse; -import com.collarmc.api.groups.http.ValidateGroupTokenRequest; +import com.collarmc.api.groups.http.*; import com.collarmc.api.http.HttpException; import com.collarmc.api.http.HttpException.NotFoundException; import com.collarmc.api.http.RequestContext; @@ -16,6 +14,7 @@ import com.collarmc.protocol.groups.*; import com.collarmc.protocol.messaging.SendMessageRequest; import com.collarmc.protocol.messaging.SendMessageResponse; +import com.collarmc.security.ApiToken; import com.collarmc.security.messages.Cipher; import com.collarmc.security.messages.CipherException; import com.collarmc.security.messages.GroupMessage; @@ -24,8 +23,8 @@ import com.collarmc.server.services.location.NearbyGroups; import com.collarmc.server.services.profiles.ProfileCache; import com.collarmc.server.session.SessionManager; -import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; import com.google.common.io.BaseEncoding; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -310,7 +309,7 @@ public Optional acknowledgeJoin(ClientIdentity identity, Ackno public Optional updateNearbyGroups(NearbyGroups.Result result) { BatchProtocolResponse response = new BatchProtocolResponse(); result.add.forEach((groupId, nearbyGroup) -> { - Group group = new Group(groupId, null, GroupType.NEARBY, Set.of()); + Group group = new Group(groupId, null, GroupType.NEARBY, Set.of(), ImmutableSet.of()); Map> groupToMembers = new HashMap<>(); group = group.addMembers(ImmutableList.copyOf(nearbyGroup.players), MembershipRole.MEMBER, MembershipState.PENDING, groupToMembers::put); for (Map.Entry> memberEntry : groupToMembers.entrySet()) { @@ -446,7 +445,33 @@ public void validateGroupToken(ValidateGroupTokenRequest req) { store.findGroupsContaining(groupMembershipToken.group) .findFirst() .map(found -> found.containsMember(groupMembershipToken.profile)) - .orElseThrow(() -> new NotFoundException("not found")); + .orElseThrow(NotFoundException::new); + } + + public UpdateGroupMembershipResponse updateMembers(RequestContext ctx, UpdateGroupMembershipRequest req) { + return store.findGroup(req.group) + .filter(group -> group.members.stream().anyMatch( + member -> member.profile.id.equals(ctx.owner)) + || isMemberWithAnyRole(group, ctx.owner, ctx.groupRoles)).map(group -> { + if (req.role == null) { + return store.removeMember(group.id, req.profile) + .map(UpdateGroupMembershipResponse::new) + .orElseThrow(NotFoundException::new); + } else { + return store.updateMember(group.id, req.profile, req.role, MembershipState.ACCEPTED) + .map(UpdateGroupMembershipResponse::new) + .orElseThrow(NotFoundException::new); + } + }) + .orElseThrow(NotFoundException::new); + } + + private static boolean isMemberWithAnyRole(Group group, UUID uuid, Set roles) { + return roles.stream().anyMatch(membershipRole -> isMemberWithRole(group, uuid, membershipRole)); + } + + private static boolean isMemberWithRole(Group group, UUID uuid, MembershipRole role) { + return group.members.stream().anyMatch(member -> member.player.identity.id().equals(uuid) && (role == null || member.membershipRole.equals(role))); } public interface MessageCreator { diff --git a/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java b/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java index 8aba1855..5ee047d9 100644 --- a/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java +++ b/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java @@ -4,6 +4,8 @@ import com.collarmc.api.identity.ClientIdentity; import com.collarmc.api.profiles.PublicProfile; import com.collarmc.api.session.Player; +import com.collarmc.security.ApiToken; +import com.collarmc.security.TokenCrypter; import com.collarmc.server.services.profiles.ProfileCache; import com.collarmc.server.session.SessionManager; import com.mongodb.client.MongoCollection; @@ -13,9 +15,13 @@ import com.mongodb.client.model.UpdateOptions; import com.mongodb.client.result.DeleteResult; import com.mongodb.client.result.UpdateResult; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import org.bson.Document; +import org.bson.types.Binary; import javax.annotation.Nonnull; +import java.io.IOException; import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -26,6 +32,8 @@ public final class GroupStore { + private static final Logger LOGGER = LogManager.getLogger(GroupStore.class.getName()); + private static final String FIELD_ID = "id"; private static final String FIELD_NAME = "name"; private static final String FIELD_TYPE = "type"; @@ -33,14 +41,17 @@ public final class GroupStore { private static final String FIELD_MEMBER_ROLE = "role"; private static final String FIELD_MEMBER_STATE = "state"; private static final String FIELD_MEMBER_PROFILE_ID = "profileId"; + private static final String FIELD_TOKENS = "tokens"; private final ProfileCache profiles; private final SessionManager sessions; private final MongoCollection docs; + private final TokenCrypter crypter; - public GroupStore(ProfileCache profiles, SessionManager sessions, MongoDatabase database) { + public GroupStore(ProfileCache profiles, SessionManager sessions, TokenCrypter crypter, MongoDatabase database) { this.profiles = profiles; this.sessions = sessions; + this.crypter = crypter; this.docs = database.getCollection("groups"); } @@ -146,15 +157,39 @@ private Group mapFromDocument(Document doc) { UUID groupId = doc.get(FIELD_ID, UUID.class); GroupType groupType = GroupType.valueOf(doc.getString(FIELD_TYPE)); String name = doc.getString(FIELD_NAME); - return new Group(groupId, name, groupType, members); + List tokens = doc.getList(FIELD_TOKENS, Binary.class, List.of()); + return new Group(groupId, name, groupType, members, tokens.stream() + .map(binary -> { + try { + return ApiToken.deserialize(crypter, binary.getData()); + } catch (IOException e) { + LOGGER.error("could not decode group token", e); + return null; + } + }) + .filter(Objects::isNull) + .collect(Collectors.toSet()) + ); } - static Document mapToDocument(Group group) { + private Document mapToDocument(Group group) { Map doc = new HashMap<>(); doc.put(FIELD_ID, group.id); doc.put(FIELD_NAME, group.name); doc.put(FIELD_TYPE, group.type.name()); doc.put(FIELD_MEMBERS, mapToMembersList(group.members)); + doc.put(FIELD_TOKENS, group.tokens.stream().map(token -> { + try { + return token.serializeToBytes(crypter); + } catch (IOException e) { + LOGGER.error("could not encode group token", e); + return null; + } + }) + .filter(Objects::nonNull) + .map(Binary::new) + .collect(Collectors.toList()) + ); return new Document(doc); } diff --git a/server/src/test/java/com/collarmc/server/http/ApiTokenTest.java b/server/src/test/java/com/collarmc/server/http/ApiTokenTest.java index b021510d..bb5bb071 100644 --- a/server/src/test/java/com/collarmc/server/http/ApiTokenTest.java +++ b/server/src/test/java/com/collarmc/server/http/ApiTokenTest.java @@ -1,11 +1,14 @@ package com.collarmc.server.http; +import com.collarmc.api.groups.MembershipRole; +import com.collarmc.security.ApiToken; import com.collarmc.api.http.RequestContext; import com.collarmc.api.profiles.Profile; import com.collarmc.api.profiles.ProfileService; import com.collarmc.server.junit.MongoDatabaseTestRule; +import com.collarmc.server.security.TokenCrypterImpl; import com.collarmc.server.security.hashing.PasswordHashing; -import com.collarmc.server.services.authentication.TokenCrypter; +import com.collarmc.security.TokenCrypter; import com.collarmc.server.services.profiles.ProfileServiceServer; import org.junit.Assert; import org.junit.Before; @@ -13,6 +16,7 @@ import org.junit.Test; import java.util.Date; +import java.util.Set; import java.util.concurrent.TimeUnit; public class ApiTokenTest { @@ -29,11 +33,12 @@ public void services() { @Test public void roundTrip() throws Exception { Profile profile = profiles.createProfile(RequestContext.ANON, new ProfileServiceServer.CreateProfileRequest("bob@example.com", "password", "Bob UwU")).profile; - ApiToken token = new ApiToken(profile.id, new Date().getTime() * TimeUnit.HOURS.toMillis(24), profile.roles); - TokenCrypter crypter = new TokenCrypter("helloworld"); + ApiToken token = new ApiToken(profile.id, new Date().getTime() * TimeUnit.HOURS.toMillis(24), profile.roles, Set.of(MembershipRole.MEMBER)); + TokenCrypter crypter = new TokenCrypterImpl("helloworld"); String tokenString = token.serialize(crypter); ApiToken deserialized = ApiToken.deserialize(crypter, tokenString); - Assert.assertEquals(deserialized.profileId, token.profileId); + Assert.assertEquals(deserialized.id, token.id); Assert.assertEquals(deserialized.expiresAt, token.expiresAt); + Assert.assertTrue(deserialized.groupRoles.contains(MembershipRole.MEMBER)); } } diff --git a/server/src/test/java/com/collarmc/server/services/groups/GroupStoreTest.java b/server/src/test/java/com/collarmc/server/services/groups/GroupStoreTest.java index 36da352e..10141f7a 100644 --- a/server/src/test/java/com/collarmc/server/services/groups/GroupStoreTest.java +++ b/server/src/test/java/com/collarmc/server/services/groups/GroupStoreTest.java @@ -7,9 +7,11 @@ import com.collarmc.api.profiles.ProfileService; import com.collarmc.api.profiles.ProfileService.CreateProfileRequest; import com.collarmc.api.session.Player; +import com.collarmc.security.TokenCrypter; import com.collarmc.security.mojang.MinecraftPlayer; import com.collarmc.server.configuration.Configuration; import com.collarmc.server.junit.MongoDatabaseTestRule; +import com.collarmc.server.security.TokenCrypterImpl; import com.collarmc.server.services.profiles.ProfileCache; import com.collarmc.server.services.profiles.ProfileServiceServer; import com.collarmc.server.session.SessionManager; @@ -27,10 +29,11 @@ public class GroupStoreTest { @Test public void crud() { + TokenCrypter crypter = new TokenCrypterImpl("insecureTokenCrypterPassword"); ProfileService profiles = new ProfileServiceServer(dbRule.db, Configuration.defaultConfiguration().passwordHashing); ProfileCache profileCache = new ProfileCache(profiles); Profile ownerProfile = profiles.createProfile(RequestContext.ANON, new CreateProfileRequest("owner@example.com", "cute", "owner")).profile; - GroupStore store = new GroupStore(profileCache, new SessionManager(Utils.messagePackMapper(), null), dbRule.db); + GroupStore store = new GroupStore(profileCache, new SessionManager(Utils.messagePackMapper(), null), crypter, dbRule.db); UUID groupId = UUID.randomUUID(); Player owner = new Player(new ClientIdentity(ownerProfile.id, null), new MinecraftPlayer(UUID.randomUUID(), "2b2t.org", 1)); diff --git a/shared/src/main/java/com/collarmc/api/groups/Group.java b/shared/src/main/java/com/collarmc/api/groups/Group.java index 4a3cf19a..7ee706eb 100644 --- a/shared/src/main/java/com/collarmc/api/groups/Group.java +++ b/shared/src/main/java/com/collarmc/api/groups/Group.java @@ -1,6 +1,9 @@ package com.collarmc.api.groups; import com.collarmc.api.session.Player; +import com.collarmc.security.ApiToken; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import com.google.common.collect.ImmutableSet; @@ -17,11 +20,22 @@ public final class Group { public final GroupType type; @JsonProperty("members") public final Set members; + @JsonIgnore + public final Set tokens; + @JsonCreator public Group(@JsonProperty("id") UUID id, @JsonProperty("name") String name, @JsonProperty("type") GroupType type, @JsonProperty("members") Set members) { + this(id, name, type, members, ImmutableSet.of()); + } + + public Group(UUID id, + String name, + GroupType type, + Set members, + Set tokens) { this.id = id; if (name != null && name.length() > 100) { throw new IllegalArgumentException("name must be 100 characters or less"); @@ -29,13 +43,14 @@ public Group(@JsonProperty("id") UUID id, this.name = name; this.type = type; this.members = ImmutableSet.copyOf(members); + this.tokens = ImmutableSet.copyOf(tokens); } public static Group newGroup(UUID id, String name, GroupType type, MemberSource owner, List members) { Set memberList = new HashSet<>(); memberList.add(new Member(owner.player, owner.profile, MembershipRole.OWNER, MembershipState.ACCEPTED)); members.forEach(memberSource -> memberList.add(new Member(memberSource.player, memberSource.profile, MembershipRole.MEMBER, MembershipState.PENDING))); - return new Group(id, name, type, memberList); + return new Group(id, name, type, memberList, ImmutableSet.of()); } public boolean containsMember(UUID profile) { @@ -55,7 +70,7 @@ public Group updatePlayer(MemberSource memberSource) { .findFirst().orElseThrow(() -> new IllegalStateException(memberSource.player + " is not a member of group " + id)); Map playerMemberMap = members.stream().collect(Collectors.toMap(member -> member.player, member -> member)); playerMemberMap.put(memberSource.player, new Member(memberSource.player, memberSource.profile, memberToUpdate.membershipRole, memberToUpdate.membershipState)); - return new Group(id, name, type, ImmutableSet.copyOf(playerMemberMap.values())); + return new Group(id, name, type, ImmutableSet.copyOf(playerMemberMap.values()), tokens); } public Group updateMembershipRole(Player player, MembershipRole newMembershipRole) { @@ -63,7 +78,7 @@ public Group updateMembershipRole(Player player, MembershipRole newMembershipRol .findFirst().orElseThrow(() -> new IllegalStateException(player + " not a member of group " + id)); Set members = this.members.stream().filter(entry -> !entry.player.equals(player)).collect(Collectors.toSet()); members.add(member.updateMembershipRole(newMembershipRole)); - return new Group(id, name, type, members); + return new Group(id, name, type, members, tokens); } public Group updateMembershipState(Player player, MembershipState newMembershipState) { @@ -71,12 +86,12 @@ public Group updateMembershipState(Player player, MembershipState newMembershipS .findFirst().orElseThrow(() -> new IllegalStateException(player + " not a member of group " + id)); Set members = this.members.stream().filter(entry -> !entry.player.equals(player)).collect(Collectors.toSet()); members.add(member.updateMembershipState(newMembershipState)); - return new Group(id, name, type, members); + return new Group(id, name, type, members, tokens); } public Group removeMember(Player player) { Set members = this.members.stream().filter(member -> !member.player.equals(player)).collect(Collectors.toSet()); - return new Group(id, name, type, members); + return new Group(id, name, type, members, tokens); } public Group addMembers(List players, MembershipRole role, MembershipState membershipState, BiConsumer> newMemberConsumer) { @@ -89,7 +104,7 @@ public Group addMembers(List players, MembershipRole role, Members newMembers.add(newMember); } }); - Group group = new Group(id, name, type, ImmutableSet.copyOf(playerMemberMap.values())); + Group group = new Group(id, name, type, ImmutableSet.copyOf(playerMemberMap.values()), tokens); newMemberConsumer.accept(group, newMembers); return group; } diff --git a/shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipRequest.java b/shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipRequest.java new file mode 100644 index 00000000..15899f98 --- /dev/null +++ b/shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipRequest.java @@ -0,0 +1,24 @@ +package com.collarmc.api.groups.http; + +import com.collarmc.api.groups.MembershipRole; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +public final class UpdateGroupMembershipRequest { + @JsonProperty("group") + public final UUID group; + @JsonProperty("profile") + public final UUID profile; + @JsonProperty("role") + public final MembershipRole role; + + public UpdateGroupMembershipRequest( + @JsonProperty("group") UUID group, + @JsonProperty("profile") UUID profile, + @JsonProperty("role") MembershipRole role) { + this.group = group; + this.profile = profile; + this.role = role; + } +} diff --git a/shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipResponse.java b/shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipResponse.java new file mode 100644 index 00000000..56a741c2 --- /dev/null +++ b/shared/src/main/java/com/collarmc/api/groups/http/UpdateGroupMembershipResponse.java @@ -0,0 +1,13 @@ +package com.collarmc.api.groups.http; + +import com.collarmc.api.groups.Group; +import com.fasterxml.jackson.annotation.JsonProperty; + +public final class UpdateGroupMembershipResponse { + @JsonProperty("group") + public final Group group; + + public UpdateGroupMembershipResponse(@JsonProperty("group") Group group) { + this.group = group; + } +} diff --git a/shared/src/main/java/com/collarmc/api/http/HttpException.java b/shared/src/main/java/com/collarmc/api/http/HttpException.java index f852e72d..1e262c87 100644 --- a/shared/src/main/java/com/collarmc/api/http/HttpException.java +++ b/shared/src/main/java/com/collarmc/api/http/HttpException.java @@ -41,6 +41,11 @@ public ForbiddenException(String message) { } public final static class NotFoundException extends HttpException { + + public NotFoundException() { + this("not found"); + } + public NotFoundException(String message) { super(404, message); } diff --git a/shared/src/main/java/com/collarmc/api/http/RequestContext.java b/shared/src/main/java/com/collarmc/api/http/RequestContext.java index 58b0239d..fb53b721 100644 --- a/shared/src/main/java/com/collarmc/api/http/RequestContext.java +++ b/shared/src/main/java/com/collarmc/api/http/RequestContext.java @@ -1,5 +1,6 @@ package com.collarmc.api.http; +import com.collarmc.api.groups.MembershipRole; import com.collarmc.api.http.HttpException.UnauthorisedException; import com.collarmc.api.identity.ClientIdentity; import com.collarmc.api.profiles.Role; @@ -11,23 +12,25 @@ public final class RequestContext { - public static final RequestContext ANON = new RequestContext(UUID.fromString("00000000-0000-0000-0000-000000000000"), ImmutableSet.of()); - public static final RequestContext SERVER = new RequestContext(UUID.fromString("99999999-9999-9999-9999-999999999999"), ImmutableSet.of()); + public static final RequestContext ANON = new RequestContext(UUID.fromString("00000000-0000-0000-0000-000000000000"), ImmutableSet.of(), ImmutableSet.of()); + public static final RequestContext SERVER = new RequestContext(UUID.fromString("99999999-9999-9999-9999-999999999999"), ImmutableSet.of(), ImmutableSet.of()); public final UUID owner; public final Set roles; + public final Set groupRoles; - public RequestContext(UUID owner, Set roles) { + public RequestContext(UUID owner, Set roles, Set groupRoles) { this.owner = owner; this.roles = roles; + this.groupRoles = groupRoles; } public static RequestContext from(UUID profileId) { - return new RequestContext(profileId, ImmutableSet.of(Role.PLAYER)); + return new RequestContext(profileId, ImmutableSet.of(Role.PLAYER), ImmutableSet.of()); } public static RequestContext from(ClientIdentity identity) { - return new RequestContext(identity.id(), ImmutableSet.of(Role.PLAYER)); + return new RequestContext(identity.id(), ImmutableSet.of(Role.PLAYER), ImmutableSet.of()); } public void assertAnonymous() { diff --git a/shared/src/main/java/com/collarmc/security/ApiToken.java b/shared/src/main/java/com/collarmc/security/ApiToken.java new file mode 100644 index 00000000..9b901692 --- /dev/null +++ b/shared/src/main/java/com/collarmc/security/ApiToken.java @@ -0,0 +1,123 @@ +package com.collarmc.security; + +import com.collarmc.api.groups.MembershipRole; +import com.collarmc.api.http.RequestContext; +import com.collarmc.api.profiles.Role; +import com.google.common.io.BaseEncoding; + +import java.io.*; +import java.util.Date; +import java.util.HashSet; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.TimeUnit; + +/** + * Used for authorizing API calls + */ +public class ApiToken { + + private static final int VERSION = 3; + + public final UUID id; + public final long expiresAt; + public final Set roles; + public final Set groupRoles; + + public ApiToken(UUID profileId, long expiresAt, Set roles, Set groupRoles) { + this.id = profileId; + this.expiresAt = expiresAt; + this.roles = roles; + this.groupRoles = groupRoles; + } + + public ApiToken(UUID profileId, Set roles, Set groupRoles) { + this.id = profileId; + this.roles = roles; + this.groupRoles = groupRoles; + this.expiresAt = new Date().getTime() + TimeUnit.DAYS.toMillis(7); + } + + public boolean isExpired() { + return new Date().after(new Date(expiresAt)); + } + + public String serialize(TokenCrypter crypter) throws IOException { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + try (DataOutputStream dataStream = new DataOutputStream(outputStream)) { + dataStream.write(VERSION); + dataStream.writeUTF(id.toString()); + dataStream.writeLong(expiresAt); + dataStream.writeInt(roles.size()); + for (Role role : roles) { + dataStream.writeInt(role.ordinal()); + } + dataStream.writeInt(groupRoles.size()); + for (MembershipRole token : groupRoles) { + dataStream.writeInt(token.ordinal()); + } + } + return BaseEncoding.base64Url().encode(crypter.crypt(outputStream.toByteArray())); + } + } + + public byte[] serializeToBytes(TokenCrypter crypter) throws IOException { + try (ByteArrayOutputStream outputStream = new ByteArrayOutputStream()) { + try (DataOutputStream dataStream = new DataOutputStream(outputStream)) { + dataStream.write(VERSION); + dataStream.writeUTF(id.toString()); + dataStream.writeLong(expiresAt); + dataStream.writeInt(roles.size()); + for (Role role : roles) { + dataStream.writeInt(role.ordinal()); + } + dataStream.writeInt(groupRoles.size()); + for (MembershipRole token : groupRoles) { + dataStream.writeInt(token.ordinal()); + } + } + return crypter.crypt(outputStream.toByteArray()); + } + } + + public static ApiToken deserialize(TokenCrypter crypter, byte[] bytes) throws IOException { + byte[] decryptedBytes = crypter.decrypt(bytes); + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(decryptedBytes)) { + try (DataInputStream dataStream = new DataInputStream(inputStream)) { + int version = dataStream.read(); + String uuidAsString = dataStream.readUTF(); + long expiresAt = dataStream.readLong(); + Set roles = new HashSet<>(); + Set groupRoles = new HashSet<>(); + int rolesCount = dataStream.readInt(); + for (int i = 0; i < rolesCount; i++) { + int roleId = dataStream.readInt(); + roles.add(Role.values()[roleId]); + } + switch (version) { + case 2: + break; + case 3: + int groupRolesCount = dataStream.readInt(); + for (int i = 0; i < groupRolesCount; i++) { + int roleId = dataStream.readInt(); + groupRoles.add(MembershipRole.values()[roleId]); + } + break; + default: + throw new IllegalStateException("unknown version " + version); + } + return new ApiToken(UUID.fromString(uuidAsString), expiresAt, roles, groupRoles); + } + } + } + + public static ApiToken deserialize(TokenCrypter crypter, String token) throws IOException { + byte[] bytes = BaseEncoding.base64Url().decode(token); + return deserialize(crypter, bytes); + } + + public RequestContext fromToken() { + return new RequestContext(id, roles, groupRoles); + } +} diff --git a/shared/src/main/java/com/collarmc/security/TokenCrypter.java b/shared/src/main/java/com/collarmc/security/TokenCrypter.java new file mode 100644 index 00000000..789d7842 --- /dev/null +++ b/shared/src/main/java/com/collarmc/security/TokenCrypter.java @@ -0,0 +1,8 @@ +package com.collarmc.security; + +public interface TokenCrypter { + /** decrypt token **/ + byte[] decrypt(byte[] bytes); + /** encrypt token **/ + byte[] crypt(byte[] bytes); +} diff --git a/tests/src/test/java/com/collarmc/tests/textures/TexturesTest.java b/tests/src/test/java/com/collarmc/tests/textures/TexturesTest.java index 1aeb0001..a7c3524c 100644 --- a/tests/src/test/java/com/collarmc/tests/textures/TexturesTest.java +++ b/tests/src/test/java/com/collarmc/tests/textures/TexturesTest.java @@ -27,6 +27,7 @@ import java.io.IOException; import java.util.ArrayList; import java.util.Optional; +import java.util.Set; import java.util.UUID; import java.util.concurrent.atomic.AtomicReference; @@ -39,13 +40,13 @@ protected void withServices(Services services) { Profile profile = services.profiles.getProfile(RequestContext.SERVER, ProfileServiceServer.GetProfileRequest.byEmail("alice@example.com")).profile; ByteSource source = Resources.asByteSource(Resources.getResource("cat.png")); try { - services.textures.createTexture(new RequestContext(profile.id, profile.roles), new CreateTextureRequest(profile.id, null, TextureType.AVATAR, source.read())); + services.textures.createTexture(new RequestContext(profile.id, profile.roles, Set.of()), new CreateTextureRequest(profile.id, null, TextureType.AVATAR, source.read())); } catch (IOException e) { throw new IllegalStateException(e); } try { - services.textures.createTexture(new RequestContext(profile.id, profile.roles), new CreateTextureRequest(null, groupId, TextureType.AVATAR, source.read())); + services.textures.createTexture(new RequestContext(profile.id, profile.roles, Set.of()), new CreateTextureRequest(null, groupId, TextureType.AVATAR, source.read())); } catch (IOException e) { throw new RuntimeException(e); } From 323bf8b3186817f1211c77a647343d030f3b513e Mon Sep 17 00:00:00 2001 From: orsond Date: Wed, 27 Oct 2021 09:06:15 +1100 Subject: [PATCH 2/3] NEW: create group management tokens --- .../collarmc/client/api/http/RESTClient.java | 28 +++++++++++---- .../java/com/collarmc/server/WebServer.java | 16 +++++---- .../server/services/groups/GroupService.java | 36 +++++++++++++++---- .../server/services/groups/GroupStore.java | 4 ++- .../java/com/collarmc/api/groups/Group.java | 11 ++++++ .../CreateGroupManagementTokenRequest.java | 15 ++++++++ .../CreateGroupManagementTokenResponse.java | 9 +++++ ...=> CreateGroupMembershipTokenRequest.java} | 4 +-- ...> CreateGroupMembershipTokenResponse.java} | 4 +-- 9 files changed, 103 insertions(+), 24 deletions(-) create mode 100644 shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenRequest.java create mode 100644 shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenResponse.java rename shared/src/main/java/com/collarmc/api/groups/http/{CreateGroupTokenRequest.java => CreateGroupMembershipTokenRequest.java} (61%) rename shared/src/main/java/com/collarmc/api/groups/http/{CreateGroupTokenResponse.java => CreateGroupMembershipTokenResponse.java} (58%) diff --git a/client/src/main/java/com/collarmc/client/api/http/RESTClient.java b/client/src/main/java/com/collarmc/client/api/http/RESTClient.java index f9e638d5..dec6fcdb 100644 --- a/client/src/main/java/com/collarmc/client/api/http/RESTClient.java +++ b/client/src/main/java/com/collarmc/client/api/http/RESTClient.java @@ -2,8 +2,9 @@ import com.collarmc.api.authentication.AuthenticationService.LoginRequest; import com.collarmc.api.authentication.AuthenticationService.LoginResponse; -import com.collarmc.api.groups.http.CreateGroupTokenRequest; -import com.collarmc.api.groups.http.CreateGroupTokenResponse; +import com.collarmc.api.groups.http.CreateGroupManagementTokenResponse; +import com.collarmc.api.groups.http.CreateGroupMembershipTokenRequest; +import com.collarmc.api.groups.http.CreateGroupMembershipTokenResponse; import com.collarmc.api.groups.Group; import com.collarmc.api.groups.http.ValidateGroupTokenRequest; import com.collarmc.api.http.HttpException; @@ -56,16 +57,29 @@ public List groups(String apiToken) { } /** - * Creates a group token used to verify that + * Creates a token used to verify that a collar player is member of a group * @param apiToken of the user * @param group id of group * @return response */ - public CreateGroupTokenResponse createGroupMembershipToken(String apiToken, UUID group) { - Request authorization = Request.url(url("groups", "token")) + public CreateGroupMembershipTokenResponse createGroupMembershipToken(String apiToken, UUID group) { + Request authorization = Request.url(url("groups", "token/membership")) .addHeader("Authorization", "Bearer " + apiToken) - .postJson(new CreateGroupTokenRequest(group)); - return http.execute(authorization, Response.json(CreateGroupTokenResponse.class)); + .postJson(new CreateGroupMembershipTokenRequest(group)); + return http.execute(authorization, Response.json(CreateGroupMembershipTokenResponse.class)); + } + + /** + * Creates a token used to manage a collar group + * @param apiToken of the user + * @param group id of group + * @return response + */ + public CreateGroupManagementTokenResponse createGroupManagementToken(String apiToken, UUID group) { + Request authorization = Request.url(url("groups", "token/management")) + .addHeader("Authorization", "Bearer " + apiToken) + .postJson(new CreateGroupMembershipTokenRequest(group)); + return http.execute(authorization, Response.json(CreateGroupManagementTokenResponse.class)); } /** diff --git a/server/src/main/java/com/collarmc/server/WebServer.java b/server/src/main/java/com/collarmc/server/WebServer.java index aebbff08..1a62a6c5 100644 --- a/server/src/main/java/com/collarmc/server/WebServer.java +++ b/server/src/main/java/com/collarmc/server/WebServer.java @@ -4,8 +4,7 @@ import com.collarmc.api.groups.Group; import com.collarmc.api.groups.GroupType; import com.collarmc.api.groups.MembershipRole; -import com.collarmc.api.groups.http.CreateGroupTokenRequest; -import com.collarmc.api.groups.http.UpdateGroupMembershipRequest; +import com.collarmc.api.groups.http.*; import com.collarmc.api.http.*; import com.collarmc.api.http.HttpException.BadRequestException; import com.collarmc.api.http.HttpException.NotFoundException; @@ -22,7 +21,6 @@ import com.collarmc.security.ApiToken; import com.collarmc.server.http.HandlebarsTemplateEngine; import com.collarmc.security.TokenCrypter; -import com.collarmc.api.groups.http.ValidateGroupTokenRequest; import com.collarmc.server.services.textures.TextureService; import com.collarmc.server.session.ClientRegistrationService; import com.collarmc.server.session.ClientRegistrationService.RegisterClientRequest; @@ -223,11 +221,17 @@ public void start(Consumer callback) throws Exception { services.groups.validateGroupToken(req); return "OK"; }, services.jsonMapper::writeValueAsString); - post("/token", (request, response) -> { + post("/token/membership", (request, response) -> { RequestContext context = from(request); context.assertNotAnonymous(); - CreateGroupTokenRequest req = services.jsonMapper.readValue(request.bodyAsBytes(), CreateGroupTokenRequest.class); - return services.groups.createGroupToken(context, req); + CreateGroupMembershipTokenRequest req = services.jsonMapper.readValue(request.bodyAsBytes(), CreateGroupMembershipTokenRequest.class); + return services.groups.createGroupMembershipToken(context, req); + }, services.jsonMapper::writeValueAsString); + post("/token/management", (request, response) -> { + RequestContext context = from(request); + context.assertNotAnonymous(); + CreateGroupManagementTokenRequest req = services.jsonMapper.readValue(request.bodyAsBytes(), CreateGroupManagementTokenRequest.class); + return services.groups.createGroupManagementToken(context, req); }, services.jsonMapper::writeValueAsString); post("/members/add", (request, response) -> { RequestContext context = from(request); diff --git a/server/src/main/java/com/collarmc/server/services/groups/GroupService.java b/server/src/main/java/com/collarmc/server/services/groups/GroupService.java index 969f8a41..388e65d7 100644 --- a/server/src/main/java/com/collarmc/server/services/groups/GroupService.java +++ b/server/src/main/java/com/collarmc/server/services/groups/GroupService.java @@ -3,8 +3,8 @@ import com.collarmc.api.friends.Status; import com.collarmc.api.groups.*; import com.collarmc.api.groups.http.*; -import com.collarmc.api.http.HttpException; import com.collarmc.api.http.HttpException.NotFoundException; +import com.collarmc.api.http.HttpException.ServerErrorException; import com.collarmc.api.http.RequestContext; import com.collarmc.api.identity.ClientIdentity; import com.collarmc.api.profiles.Profile; @@ -15,6 +15,7 @@ import com.collarmc.protocol.messaging.SendMessageRequest; import com.collarmc.protocol.messaging.SendMessageResponse; import com.collarmc.security.ApiToken; +import com.collarmc.security.TokenCrypter; import com.collarmc.security.messages.Cipher; import com.collarmc.security.messages.CipherException; import com.collarmc.security.messages.GroupMessage; @@ -29,6 +30,7 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import java.io.IOException; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.*; @@ -42,12 +44,14 @@ public final class GroupService { private final GroupStore store; private final ProfileCache profiles; private final SessionManager sessions; + private final TokenCrypter crypter; private final Cipher cipher; - public GroupService(GroupStore store, ProfileCache profiles, SessionManager sessions, Cipher cipher) { + public GroupService(GroupStore store, ProfileCache profiles, SessionManager sessions, TokenCrypter crypter, Cipher cipher) { this.store = store; this.profiles = profiles; this.sessions = sessions; + this.crypter = crypter; this.cipher = cipher; } @@ -419,18 +423,38 @@ public Optional findGroup(UUID groupId) { return store.findGroup(groupId); } - public CreateGroupTokenResponse createGroupToken(RequestContext ctx, CreateGroupTokenRequest request) { + public CreateGroupMembershipTokenResponse createGroupMembershipToken(RequestContext ctx, CreateGroupMembershipTokenRequest request) { byte[] token = store.findGroup(request.group).filter(found -> found.members.stream().anyMatch(member -> ctx.callerIs(member.profile.id))) .map(found -> new GroupMembershipToken(found.id, ctx.owner, Instant.now().plus(1, ChronoUnit.DAYS))) .map(groupMembershipToken -> { try { return cipher.encrypt(groupMembershipToken.serialize()); } catch (CipherException e) { - throw new HttpException.ServerErrorException("token generation failed", e); + throw new ServerErrorException("token generation failed", e); } }) .orElseThrow(() -> new NotFoundException("group not found")); - return new CreateGroupTokenResponse(BaseEncoding.base64Url().encode(token)); + return new CreateGroupMembershipTokenResponse(BaseEncoding.base64Url().encode(token)); + } + + /** + * Create a group API token that can be used to manage or act on behalf of the group + * @param ctx of the request + * @param req specifying token parameters + * @return response + */ + public CreateGroupManagementTokenResponse createGroupManagementToken(RequestContext ctx, CreateGroupManagementTokenRequest req) { + ApiToken token = new ApiToken(req.group, Set.of(), Set.of(req.role)); + store.findGroup(req.group) + .filter(group -> group.members.stream().noneMatch(member -> MembershipRole.OWNER.equals(member.membershipRole) && member.profile.id.equals(ctx.owner))) + .map(group -> group.addApiToken(token)) + .flatMap(store::upsert) + .orElseThrow(NotFoundException::new); + try { + return new CreateGroupManagementTokenResponse(token.serialize(crypter)); + } catch (IOException e) { + throw new ServerErrorException("problem serializing group api token", e); + } } public void validateGroupToken(ValidateGroupTokenRequest req) { @@ -439,7 +463,7 @@ public void validateGroupToken(ValidateGroupTokenRequest req) { try { groupMembershipToken = new GroupMembershipToken(cipher.decrypt(token)); } catch (CipherException e) { - throw new HttpException.ServerErrorException("bad token", e); + throw new ServerErrorException("bad token", e); } groupMembershipToken.assertValid(req.group); store.findGroupsContaining(groupMembershipToken.group) diff --git a/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java b/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java index 5ee047d9..6b1fc06b 100644 --- a/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java +++ b/server/src/main/java/com/collarmc/server/services/groups/GroupStore.java @@ -58,13 +58,15 @@ public GroupStore(ProfileCache profiles, SessionManager sessions, TokenCrypter c /** * Upsert group into the store * @param group to store + * @return group */ - public void upsert(Group group) { + public Optional upsert(Group group) { Document document = mapToDocument(group); UpdateResult result = docs.replaceOne(eq(FIELD_ID, group.id), document, new ReplaceOptions().upsert(true)); if (!result.wasAcknowledged()) { throw new IllegalStateException("group " + group.id + " could not be upserted"); } + return findGroup(group.id); } /** diff --git a/shared/src/main/java/com/collarmc/api/groups/Group.java b/shared/src/main/java/com/collarmc/api/groups/Group.java index 7ee706eb..9aa874ee 100644 --- a/shared/src/main/java/com/collarmc/api/groups/Group.java +++ b/shared/src/main/java/com/collarmc/api/groups/Group.java @@ -109,6 +109,17 @@ public Group addMembers(List players, MembershipRole role, Members return group; } + /** + * Add an API token + * @param token to add + * @return new group + */ + public Group addApiToken(ApiToken token) { + Set newTokens = new HashSet<>(tokens); + newTokens.add(token); + return new Group(id, name, type, members, newTokens); + } + public MembershipRole getRole(Player sendingPlayer) { return members.stream().filter(member -> sendingPlayer.identity.id().equals(member.player.identity.id())) .findFirst().map(member -> member.membershipRole).orElse(null); diff --git a/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenRequest.java b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenRequest.java new file mode 100644 index 00000000..041bf38c --- /dev/null +++ b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenRequest.java @@ -0,0 +1,15 @@ +package com.collarmc.api.groups.http; + +import com.collarmc.api.groups.MembershipRole; + +import java.util.UUID; + +public class CreateGroupManagementTokenRequest { + public final UUID group; + public final MembershipRole role; + + public CreateGroupManagementTokenRequest(UUID group, MembershipRole role) { + this.group = group; + this.role = role; + } +} diff --git a/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenResponse.java b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenResponse.java new file mode 100644 index 00000000..33c3435e --- /dev/null +++ b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupManagementTokenResponse.java @@ -0,0 +1,9 @@ +package com.collarmc.api.groups.http; + +public class CreateGroupManagementTokenResponse { + public final String apiToken; + + public CreateGroupManagementTokenResponse(String apiToken) { + this.apiToken = apiToken; + } +} diff --git a/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupTokenRequest.java b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupMembershipTokenRequest.java similarity index 61% rename from shared/src/main/java/com/collarmc/api/groups/http/CreateGroupTokenRequest.java rename to shared/src/main/java/com/collarmc/api/groups/http/CreateGroupMembershipTokenRequest.java index 91513e26..1ef4f5af 100644 --- a/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupTokenRequest.java +++ b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupMembershipTokenRequest.java @@ -4,11 +4,11 @@ import java.util.UUID; -public class CreateGroupTokenRequest { +public class CreateGroupMembershipTokenRequest { @JsonProperty("group") public final UUID group; - public CreateGroupTokenRequest(@JsonProperty("group") UUID group) { + public CreateGroupMembershipTokenRequest(@JsonProperty("group") UUID group) { this.group = group; } } diff --git a/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupTokenResponse.java b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupMembershipTokenResponse.java similarity index 58% rename from shared/src/main/java/com/collarmc/api/groups/http/CreateGroupTokenResponse.java rename to shared/src/main/java/com/collarmc/api/groups/http/CreateGroupMembershipTokenResponse.java index 2374f016..938e60bd 100644 --- a/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupTokenResponse.java +++ b/shared/src/main/java/com/collarmc/api/groups/http/CreateGroupMembershipTokenResponse.java @@ -2,11 +2,11 @@ import com.fasterxml.jackson.annotation.JsonProperty; -public class CreateGroupTokenResponse { +public class CreateGroupMembershipTokenResponse { @JsonProperty("token") public final String token; - public CreateGroupTokenResponse(@JsonProperty("token") String token) { + public CreateGroupMembershipTokenResponse(@JsonProperty("token") String token) { this.token = token; } } From e7a053d481c5660c66db58d9b9e84a59215f3afa Mon Sep 17 00:00:00 2001 From: orsond Date: Thu, 25 Nov 2021 07:49:58 +1100 Subject: [PATCH 3/3] NEW: introduce ValidateGroupTokenResponse. --- .../server/services/groups/GroupService.java | 6 ++++- .../java/com/collarmc/api/groups/Group.java | 9 ++++++++ .../com/collarmc/api/groups/PublicGroup.java | 22 +++++++++++++++++++ .../http/ValidateGroupTokenResponse.java | 19 ++++++++++++++++ 4 files changed, 55 insertions(+), 1 deletion(-) create mode 100644 shared/src/main/java/com/collarmc/api/groups/PublicGroup.java create mode 100644 shared/src/main/java/com/collarmc/api/groups/http/ValidateGroupTokenResponse.java diff --git a/server/src/main/java/com/collarmc/server/services/groups/GroupService.java b/server/src/main/java/com/collarmc/server/services/groups/GroupService.java index 388e65d7..54b7e376 100644 --- a/server/src/main/java/com/collarmc/server/services/groups/GroupService.java +++ b/server/src/main/java/com/collarmc/server/services/groups/GroupService.java @@ -468,7 +468,11 @@ public void validateGroupToken(ValidateGroupTokenRequest req) { groupMembershipToken.assertValid(req.group); store.findGroupsContaining(groupMembershipToken.group) .findFirst() - .map(found -> found.containsMember(groupMembershipToken.profile)) + .map(found -> found.findMember(groupMembershipToken.profile).orElseThrow(NotFoundException::new)) + .map(member -> profiles.getById(member.profile.id).orElseThrow(NotFoundException::new)) + .map(profile -> new ValidateGroupTokenResponse(findGroup(req.group) + .map(Group::toPublicGroup).orElseThrow(NotFoundException::new), profile.toPublic()) + ) .orElseThrow(NotFoundException::new); } diff --git a/shared/src/main/java/com/collarmc/api/groups/Group.java b/shared/src/main/java/com/collarmc/api/groups/Group.java index 9aa874ee..4df6a4e8 100644 --- a/shared/src/main/java/com/collarmc/api/groups/Group.java +++ b/shared/src/main/java/com/collarmc/api/groups/Group.java @@ -65,6 +65,10 @@ public Optional findMember(Player player) { return members.stream().filter(member -> member.player.equals(player)).findFirst(); } + public Optional findMember(UUID profile) { + return members.stream().filter(member -> member.profile.id.equals(profile)).findFirst(); + } + public Group updatePlayer(MemberSource memberSource) { Member memberToUpdate = members.stream().filter(member -> member.player.equals(memberSource.player)) .findFirst().orElseThrow(() -> new IllegalStateException(memberSource.player + " is not a member of group " + id)); @@ -124,4 +128,9 @@ public MembershipRole getRole(Player sendingPlayer) { return members.stream().filter(member -> sendingPlayer.identity.id().equals(member.player.identity.id())) .findFirst().map(member -> member.membershipRole).orElse(null); } + + @JsonIgnore + public PublicGroup toPublicGroup() { + return new PublicGroup(id, name, type); + } } diff --git a/shared/src/main/java/com/collarmc/api/groups/PublicGroup.java b/shared/src/main/java/com/collarmc/api/groups/PublicGroup.java new file mode 100644 index 00000000..0e3ebd23 --- /dev/null +++ b/shared/src/main/java/com/collarmc/api/groups/PublicGroup.java @@ -0,0 +1,22 @@ +package com.collarmc.api.groups; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.UUID; + +public final class PublicGroup { + @JsonProperty("id") + public final UUID id; + @JsonProperty("name") + public final String name; + @JsonProperty("type") + public final GroupType type; + + public PublicGroup(@JsonProperty("id") UUID id, + @JsonProperty("name") String name, + @JsonProperty("type") GroupType type) { + this.id = id; + this.name = name; + this.type = type; + } +} diff --git a/shared/src/main/java/com/collarmc/api/groups/http/ValidateGroupTokenResponse.java b/shared/src/main/java/com/collarmc/api/groups/http/ValidateGroupTokenResponse.java new file mode 100644 index 00000000..4a2e292d --- /dev/null +++ b/shared/src/main/java/com/collarmc/api/groups/http/ValidateGroupTokenResponse.java @@ -0,0 +1,19 @@ +package com.collarmc.api.groups.http; + +import com.collarmc.api.groups.PublicGroup; +import com.collarmc.api.profiles.PublicProfile; +import com.fasterxml.jackson.annotation.JsonProperty; + +public final class ValidateGroupTokenResponse { + @JsonProperty("group") + public final PublicGroup group; + @JsonProperty("profile") + public final PublicProfile profile; + + public ValidateGroupTokenResponse( + @JsonProperty("group") PublicGroup group, + @JsonProperty("profile") PublicProfile profile) { + this.group = group; + this.profile = profile; + } +}