ditch access keys

This commit is contained in:
Minecon724 2024-08-23 16:36:55 +02:00
parent 63072a8af0
commit b108f7688a
Signed by: Minecon724
GPG key ID: 3CCC4D267742C8E8
14 changed files with 238 additions and 304 deletions

View file

@ -13,34 +13,24 @@ import eu.m724.orm.AccessLimits;
* if you were to sell access you can "tweak" some of this and rip off people * if you were to sell access you can "tweak" some of this and rip off people
*/ */
public class GlobalAccessLimits { public class GlobalAccessLimits {
public static AccessLimits kilo, mega, giga, tera;
public static void initialize() { public static void initialize() {
AccessLimits kilo = new AccessLimits(); AccessLimits kilo = new AccessLimits("kilo", true, true, 60);
kilo.label = "kilo";
kilo.thunder = true;
kilo.weather = true;
kilo.weatherRequestsHourly = 60;
AccessLimits mega = new AccessLimits();
mega.label = "plus";
mega.thunder = true;
mega.weather = true;
mega.weatherRequestsHourly = 120;
AccessLimits giga = new AccessLimits();
giga.label = "giga";
giga.thunder = true;
giga.weather = true;
giga.weatherRequestsHourly = 240;
AccessLimits tera = new AccessLimits();
tera.label = "giga";
tera.thunder = true;
tera.weather = true;
tera.weatherRequestsHourly = 480;
kilo.persist(); kilo.persist();
AccessLimits mega = new AccessLimits("mega", true, true, 120);
mega.persist(); mega.persist();
AccessLimits giga = new AccessLimits("giga", true, true, 240);
giga.persist(); giga.persist();
AccessLimits tera = new AccessLimits("tera", true, true, 480);
tera.persist(); tera.persist();
GlobalAccessLimits.kilo = kilo;
GlobalAccessLimits.mega = mega;
GlobalAccessLimits.giga = giga;
GlobalAccessLimits.tera = tera;
} }
} }

View file

@ -0,0 +1,39 @@
package eu.m724;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.json.stream.JsonParsingException;
import jakarta.ws.rs.ClientErrorException;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.ext.ExceptionMapper;
import jakarta.ws.rs.ext.Provider;
@Provider
public class GlobalExceptionHandler implements ExceptionMapper<Throwable> {
@Override
public Response toResponse(Throwable exception) {
System.out.println("andling error" + exception.getMessage());
int status = Response.Status.INTERNAL_SERVER_ERROR.getStatusCode();
String message = "An error has occurred";
if (exception instanceof ClientErrorException) {
status = ((ClientErrorException) exception).getResponse().getStatus();
message = exception.getMessage();
} else if (exception instanceof JsonParsingException) {
status = Response.Status.BAD_REQUEST.getStatusCode();
message = "Valid JSON expected"; // TODO make this error better
}
JsonObject errorJson = Json.createObjectBuilder()
.add("status", status)
.add("message", message)
.build();
return Response.status(status)
.entity(errorJson)
.build();
}
}

View file

@ -1,21 +0,0 @@
package eu.m724;
import eu.m724.auth.master.AccountService;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.inject.Inject;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
/**
* managing access keys (not master keys or accounts)
*/
@Path("/api/keys")
@Produces(MediaType.APPLICATION_JSON)
public class KeysResource {
@Inject
SecurityIdentity securityIdentity;
@Inject
AccountService accountService;
}

View file

@ -1,10 +1,8 @@
package eu.m724; package eu.m724;
import eu.m724.auth.master.AccountService; import eu.m724.orm.Token;
import eu.m724.orm.Account;
import io.quarkus.runtime.StartupEvent; import io.quarkus.runtime.StartupEvent;
import jakarta.enterprise.event.Observes; import jakarta.enterprise.event.Observes;
import jakarta.inject.Inject;
import jakarta.inject.Singleton; import jakarta.inject.Singleton;
import jakarta.transaction.Transactional; import jakarta.transaction.Transactional;
@ -12,15 +10,18 @@ import java.util.Base64;
@Singleton @Singleton
public class Startup { public class Startup {
@Inject
AccountService accountService;
@Transactional @Transactional
public void loadUsers(@Observes StartupEvent ignoredEvent) { public void loadUsers(@Observes StartupEvent ignoredEvent) {
Account.deleteAll(); GlobalAccessLimits.initialize();
Token.deleteAll();
byte[] adminKey = new byte[18]; byte[] adminKey = new byte[18];
accountService.add(adminKey, "admin"); Token token = new Token();
System.out.println("Admin user created: " + Base64.getEncoder().encodeToString(adminKey)); token.accessLimits = GlobalAccessLimits.kilo;
token.role = "admin";
token.tokenBytes = adminKey;
token.persist();
System.out.println("Admin token created: " + Base64.getEncoder().encodeToString(adminKey));
} }
} }

View file

@ -0,0 +1,57 @@
package eu.m724;
import eu.m724.orm.AccessLimits;
import eu.m724.orm.Token;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.MediaType;
@Path("/api/tokens")
@Produces(MediaType.APPLICATION_JSON)
public class TokensResource {
@Inject
SecurityIdentity securityIdentity;
@PUT
@Consumes(MediaType.APPLICATION_JSON)
@Path("/create")
@RolesAllowed("admin")
public JsonObject createToken(JsonObject data) {
String label = data.getString("accessLimits", null);
if (label == null) {
throw new BadRequestException("Specify access limits in the 'accessLimits' key");
}
AccessLimits accessLimits = AccessLimits.findByLabel(label);
if (accessLimits == null) {
throw new BadRequestException("Unknown access limits: " + label);
}
String token = Token.generate(accessLimits);
return Json.createObjectBuilder()
.add("token", token)
.build();
}
@GET
@Path("/me")
@RolesAllowed({"user", "admin"})
public JsonObject me() {
Token token = securityIdentity.getAttribute("token");
String tokenEncoded = securityIdentity.getPrincipal().getName();
String censoredToken = tokenEncoded.substring(0, 5) + "..." + tokenEncoded.substring(tokenEncoded.length() - 5);
return Json.createObjectBuilder()
.add("token", censoredToken)
.add("role", token.role)
.add("accessLimits", token.accessLimits.label)
.build();
}
}

View file

@ -1,50 +0,0 @@
package eu.m724;
import eu.m724.auth.master.AccountService;
import eu.m724.orm.Account;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.json.Json;
import jakarta.json.JsonObject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/api/users")
@Produces(MediaType.APPLICATION_JSON)
public class UsersResource {
@Inject
SecurityIdentity securityIdentity;
@Inject
AccountService accountService;
@GET
@Path("/create")
@RolesAllowed("admin")
public JsonObject createAccount() {
String masterKey = accountService.create("user");
return Json.createObjectBuilder()
.add("masterKey", masterKey)
.build();
}
@GET
@Path("/me")
@RolesAllowed({"user", "admin"})
public JsonObject me() {
Account account = securityIdentity.getAttribute("account");
String masterKey = securityIdentity.getPrincipal().getName();
String censoredKey = masterKey.substring(0, 5) + "..." + masterKey.substring(masterKey.length() - 5);
return Json.createObjectBuilder()
.add("masterKey", censoredKey)
.add("role", account.role)
.add("accessKeys", account.accessKeys.size())
.build();
}
}

View file

@ -1,42 +0,0 @@
package eu.m724.auth.master;
import eu.m724.orm.AccessKey;
import eu.m724.orm.AccessLimits;
import eu.m724.orm.Account;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.security.SecureRandom;
import java.util.Base64;
@ApplicationScoped
public class AccessKeyService {
private final SecureRandom random = new SecureRandom();
/**
* generates an access key for an account
* @param account the account
* @param accessLimits access limits
* @return base64 encoded access key
*/
@Transactional
public String createAccessKey(Account account, AccessLimits accessLimits) {
byte[] key = new byte[18];
random.nextBytes(key);
AccessKey accessKey = new AccessKey();
accessKey.key = key;
accessKey.account = account;
accessKey.accessLimits = accessLimits;
account.accessKeys.add(accessKey);
account.persist();
return Base64.getEncoder().encodeToString(key);
}
@Transactional
public void deleteAccessKey(AccessKey accessKey) {
accessKey.account = null; // TODO hopefully that works
}
}

View file

@ -1,73 +0,0 @@
package eu.m724.auth.master;
import eu.m724.orm.AccessKey;
import eu.m724.orm.Account;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.transaction.Transactional;
import java.security.SecureRandom;
import java.util.Base64;
@ApplicationScoped
public class AccountService {
private final SecureRandom random = new SecureRandom();
/**
* find a master user by key
* @param key base64 encoded key
* @return the master user or null if wrong key or key is null
*/
@Transactional
public Account findByKey(String key) {
if (key == null) return null;
try {
return Account.find("masterKey", (Object) Base64.getDecoder().decode(key)).firstResult();
} catch (IllegalArgumentException e) {
return null;
}
}
/**
* gets an access key
* @param bytes access key as bytes
* @return the {@link AccessKey} if correct else null even if key is null
*/
@Transactional
public AccessKey findByAccessKey(byte[] bytes) {
if (bytes == null) return null;
try {
return AccessKey.find("key", (Object) bytes).firstResult();
} catch (IllegalArgumentException e) {
return null;
}
}
// TODO maybe move some of these methods somewhere else and reconsider making them static
/**
* creates an account with the specified key
* @param masterKey the desired master key
*/
@Transactional
public void add(byte[] masterKey, String role) {
Account account = new Account();
account.masterKey = masterKey;
account.role = role;
account.persist();
}
/**
* creates an account with random key
* @param role new account's role
* @return base64 encoded key
*/
public String create(String role) {
byte[] key = new byte[18]; // 144 bits of entropy
random.nextBytes(key);
add(key, role);
return Base64.getEncoder().encodeToString(key);
}
}

View file

@ -1,6 +1,6 @@
package eu.m724.auth.master; package eu.m724.auth.master;
import eu.m724.orm.Account; import eu.m724.orm.Token;
import io.quarkus.security.identity.IdentityProviderManager; import io.quarkus.security.identity.IdentityProviderManager;
import io.quarkus.security.identity.SecurityIdentity; import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.runtime.QuarkusPrincipal; import io.quarkus.security.runtime.QuarkusPrincipal;
@ -11,27 +11,23 @@ import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure; import io.smallrye.mutiny.infrastructure.Infrastructure;
import io.vertx.ext.web.RoutingContext; import io.vertx.ext.web.RoutingContext;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ApplicationScoped @ApplicationScoped
public class MyHttpAuthenticationMechanism implements HttpAuthenticationMechanism { public class MyHttpAuthenticationMechanism implements HttpAuthenticationMechanism {
@Inject
AccountService accountService;
@Override @Override
public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) { public Uni<SecurityIdentity> authenticate(RoutingContext context, IdentityProviderManager identityProviderManager) {
String encodedKey = context.request().getHeader("X-Master-Key"); String encodedToken = context.request().getHeader("X-Token");
return Uni.createFrom().item(() -> { return Uni.createFrom().item(() -> {
Account account = accountService.findByKey(encodedKey); Token token = Token.findByToken(encodedToken);
if (account != null) { if (token != null) {
QuarkusSecurityIdentity identity = QuarkusSecurityIdentity.builder() QuarkusSecurityIdentity identity = QuarkusSecurityIdentity.builder()
.setPrincipal(new QuarkusPrincipal(encodedKey)) .setPrincipal(new QuarkusPrincipal(encodedToken))
.addRole(account.role) .addRole(token.role)
.addAttribute("account", account) .addAttribute("token", token)
.build(); .build();
return (SecurityIdentity) identity; return (SecurityIdentity) identity;
} }
return null; return null;
@ -41,6 +37,6 @@ public class MyHttpAuthenticationMechanism implements HttpAuthenticationMechanis
@Override @Override
public Uni<ChallengeData> getChallenge(RoutingContext context) { public Uni<ChallengeData> getChallenge(RoutingContext context) {
return Uni.createFrom().item(new ChallengeData(401, "WWW-Authenticate", "X-Master-Key")); return Uni.createFrom().item(new ChallengeData(401, "WWW-Authenticate", "X-Token"));
} }
} }

View file

@ -1,25 +0,0 @@
package eu.m724.orm;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import jakarta.persistence.Column;
import jakarta.persistence.ManyToOne;
//@Entity
public class AccessKey extends PanacheEntity {
/**
* raw bytes of this key, it's provided to users in base64
*/
@Column(unique = true)
public byte[] key;
/**
* access limits of this key
*/
public AccessLimits accessLimits;
/**
* the user owning this access key
*/
@ManyToOne
public Account account;
}

View file

@ -6,6 +6,15 @@ import jakarta.persistence.Entity;
@Entity @Entity
public class AccessLimits extends PanacheEntity { public class AccessLimits extends PanacheEntity {
public AccessLimits() {}
public AccessLimits(String label, boolean thunder, boolean weather, int weatherRequestsHourly) {
this.label = label;
this.thunder = thunder;
this.weather = weather;
this.weatherRequestsHourly = weatherRequestsHourly;
}
/** /**
* label of these limits, displayed to user and used to identify the limits * label of these limits, displayed to user and used to identify the limits
*/ */
@ -28,4 +37,13 @@ public class AccessLimits extends PanacheEntity {
* max requests per hour * max requests per hour
*/ */
public int weatherRequestsHourly; public int weatherRequestsHourly;
/**
* find {@link AccessLimits} by label
* @param label label
* @return {@link AccessLimits} or null if not found
*/
public static AccessLimits findByLabel(String label) {
return AccessLimits.find("label", label).firstResult();
}
} }

View file

@ -1,25 +0,0 @@
package eu.m724.orm;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import io.quarkus.security.jpa.Roles;
import jakarta.persistence.CascadeType;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.OneToMany;
import java.util.HashSet;
import java.util.Set;
// TODO organize all this like work on variable names move functions etc
@Entity
public class Account extends PanacheEntity {
@Column(unique = true)
public byte[] masterKey;
@OneToMany(mappedBy = "account", cascade = CascadeType.ALL, orphanRemoval = true)
public Set<AccessKey> accessKeys = new HashSet<>();
@Roles
public String role = "user";
}

View file

@ -0,0 +1,68 @@
package eu.m724.orm;
import io.quarkus.hibernate.orm.panache.PanacheEntity;
import io.quarkus.security.jpa.Roles;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.ManyToOne;
import jakarta.transaction.Transactional;
import java.security.SecureRandom;
import java.util.Base64;
// TODO organize all this like work on variable names move functions etc
@Entity
public class Token extends PanacheEntity {
@Column(unique = true)
public byte[] tokenBytes;
/**
* access limits of this key
*/
@ManyToOne
public AccessLimits accessLimits;
@Roles
public String role = "user";
// TODO maybe move some stuff
/**
* creates a random token
* @return base64 encoded key
*/
@Transactional
public static String generate(AccessLimits accessLimits) {
byte[] tokenBytes = new byte[18]; // 144 bits of entropy
new SecureRandom().nextBytes(tokenBytes);
Token token = new Token();
token.tokenBytes = tokenBytes;
token.accessLimits = accessLimits;
token.persist();
return Base64.getEncoder().encodeToString(tokenBytes);
}
/**
* find an {@link Token} by master key
* @param tokenBytes token in bytes
* @return an {@link Token} or null if not found
*/
@Transactional
public static Token findByToken(byte[] tokenBytes) {
return Token.find("tokenBytes", (Object) tokenBytes).firstResult();
}
/**
* find an {@link Token} by master key
* @param tokenEncoded base64 encoded token
* @return an {@link Token} or null if not found
* @throws IllegalArgumentException if base64 is invalid
*/
@Transactional
public static Token findByToken(String tokenEncoded) {
return findByToken(Base64.getDecoder().decode(tokenEncoded));
}
}

View file

@ -1,11 +1,9 @@
package eu.m724.websocket; package eu.m724.websocket;
import eu.m724.auth.master.AccountService; import eu.m724.orm.Token;
import eu.m724.orm.AccessKey;
import eu.m724.websocket.packet.DisconnectReason; import eu.m724.websocket.packet.DisconnectReason;
import eu.m724.websocket.packet.clientbound.PongPacket; import eu.m724.websocket.packet.clientbound.PongPacket;
import jakarta.enterprise.context.ApplicationScoped; import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.websocket.Session; import jakarta.websocket.Session;
import org.eclipse.microprofile.config.inject.ConfigProperty; import org.eclipse.microprofile.config.inject.ConfigProperty;
@ -18,39 +16,42 @@ public class WebsocketService {
@ConfigProperty(name = "rwws.protocol_version") @ConfigProperty(name = "rwws.protocol_version")
byte protocolVersion; byte protocolVersion;
@Inject private final Map<String, Token> tokens = new ConcurrentHashMap<>();
AccountService accountService;
private final Map<String, AccessKey> accounts = new ConcurrentHashMap<>(); //
void addSession(String sessionId) { void addSession(String sessionId) {
accounts.put(sessionId, null); tokens.put(sessionId, null);
} }
void removeConnection(String sessionId) { void removeConnection(String sessionId) {
accounts.remove(sessionId); tokens.remove(sessionId);
} }
//
boolean authenticate(String sessionId, byte[] tokenBytes) {
Token token = Token.findByToken(tokenBytes);
if (token == null)
return false;
tokens.put(sessionId, token);
return true;
}
boolean isAuthenticated(String sessionId) {
return tokens.containsKey(sessionId);
}
//
void disconnect(Session session, DisconnectReason reason, String message) { void disconnect(Session session, DisconnectReason reason, String message) {
try { try {
session.close(reason.asCloseReason(message)); session.close(reason.asCloseReason(message));
} catch (IOException ignored) { } } catch (IOException ignored) { }
} }
boolean authenticate(String sessionId, byte[] bytes) {
AccessKey accessKey = accountService.findByAccessKey(bytes);
if (accessKey == null)
return false;
accounts.put(sessionId, accessKey);
return true;
}
boolean isAuthenticated(String sessionId) {
return accounts.containsKey(sessionId);
}
void pong(Session session) { void pong(Session session) {
new PongPacket().send(session); new PongPacket().send(session);
} }