♻️ Transform to IDP provider

This commit is contained in:
Andreas Dinauer 2026-03-14 18:54:50 +01:00
parent 79104dd02f
commit 7d638e6530
37 changed files with 531 additions and 326 deletions

View File

@ -1,59 +0,0 @@
package de.tavolio.bootstrap;
import de.tavolio.realm.user.UserEntity;
import de.tavolio.realm.user.UserRepo;
import de.tavolio.realm.user.UserStatus;
import de.tavolio.bootstrap.model.Account;
import de.tavolio.realm.RealmEntity;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class AccountBootstrapper
{
@Inject
UserRepo userRepo;
public void bootstrap(RealmEntity realm, List<Account> accounts)
{
for (Account account : accounts)
{
run(realm, account);
}
}
public void run(RealmEntity realm, Account account)
{
Optional<UserEntity> existingAccount = userRepo.findOptionalByRealmAndEmail(realm, account.email());
if (existingAccount.isEmpty())
{
UserEntity newAccount = new UserEntity();
newAccount.setId(UUID.randomUUID().toString());
newAccount.setEmail(account.email());
newAccount.setFirstname(account.firstname());
newAccount.setLastname(account.lastname());
newAccount.setRealm(realm);
newAccount.setStatus(UserStatus.INIT);
newAccount.setPassword(resolvePassword(account));
userRepo.persist(newAccount);
}
}
private String resolvePassword(Account account)
{
if (account.passwordFromEnv() != null)
{
return BcryptUtil.bcryptHash(System.getenv(account.passwordFromEnv()));
}
if (account.passwordPlain() != null)
{
return BcryptUtil.bcryptHash(account.passwordPlain());
}
return null;
}
}

View File

@ -9,6 +9,7 @@ import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class ClientBootstrapper
@ -23,8 +24,23 @@ public class ClientBootstrapper
{
for (Map.Entry<String, Client> clientEntry : clients.entrySet())
{
ClientEntity client = clientService.findOrCreate(realm, clientEntry);
clientRepo.persist(client);
run(realm, clientEntry);
}
}
private void run(RealmEntity realm, Map.Entry<String, Client> bootstrap)
{
Optional<ClientEntity> existingClient = clientRepo.findByRealmAndIdOptional(realm, bootstrap.getKey());
if (existingClient.isEmpty())
{
String id = bootstrap.getKey();
Client client = bootstrap.getValue();
ClientEntity entity = new ClientEntity().setId(id)
.setSecret(Credentials.resolve(client.secret()))
.setRedirectURI(client.redirectURI())
.setRealm(realm)
.setPermissions(client.permissions());
clientRepo.persist(entity);
}
}
}

View File

@ -0,0 +1,36 @@
package de.tavolio.bootstrap;
import de.tavolio.bootstrap.model.Credential;
import de.tavolio.bootstrap.model.User;
import io.quarkus.elytron.security.common.BcryptUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
public class Credentials
{
public static String resolve(Credential credential)
{
if (!StringUtils.isBlank(credential.bcrypt()))
{
if (Strings.CI.startsWithAny(credential.bcrypt(), "$2a$", "$2b$", "$2y$"))
{
return credential.bcrypt();
}
throw new IllegalStateException();
}
if (!StringUtils.isBlank(credential.env()))
{
String value = System.getenv(credential.env());
if (!StringUtils.isBlank(value))
{
return BcryptUtil.bcryptHash(value);
}
throw new IllegalStateException();
}
if (!StringUtils.isBlank(credential.plain()))
{
return BcryptUtil.bcryptHash(credential.plain());
}
throw new IllegalArgumentException("Cannot find password source.");
}
}

View File

@ -26,14 +26,11 @@ public class RealmBootstrapService
@Inject
ClientBootstrapper clientBootstrapper;
@Inject
RoleBootstrapper roleBootstrapper;
@Inject
RealmRepo realmRepo;
@Inject
AccountBootstrapper accountBootstrapper;
UserBootstrapper userBootstrapper;
@Inject
AudienceStrategyRepo audienceStrategyRepo;
@ -42,10 +39,9 @@ public class RealmBootstrapService
{
Realm realmValue = realmEntry.getValue();
RealmEntity realm = run(realmEntry.getKey(), realmEntry.getValue());
roleBootstrapper.bootstrap(realm, realmValue.roles());
keyBootstrapper.bootstrap(realm, realmValue.key());
clientBootstrapper.bootstrap(realm, realmValue.clients());
accountBootstrapper.bootstrap(realm, realmValue.accounts());
userBootstrapper.bootstrap(realm, realmValue.users());
}
public RealmEntity run(String key, Realm realm)

View File

@ -1,43 +0,0 @@
package de.tavolio.bootstrap;
import de.tavolio.bootstrap.model.Role;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.role.RoleEntity;
import de.tavolio.realm.role.RoleRepo;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.util.Map;
import java.util.UUID;
@ApplicationScoped
public class RoleBootstrapper
{
@Inject
RoleRepo roleRepo;
public void bootstrap(RealmEntity realm, Map<String, Role> roles)
{
for (Map.Entry<String, Role> roleEntry : roles.entrySet())
{
run(realm, roleEntry);
}
}
public void run(RealmEntity realm, Map.Entry<String, Role> roleEntry)
{
RoleEntity role = getOrCreateRole(realm, roleEntry.getKey());
for (String permission : roleEntry.getValue().permissions())
{
if (!role.hasPermission(permission))
{
role.getPermissions().add(permission);
}
}
roleRepo.persist(role);
}
public RoleEntity getOrCreateRole(RealmEntity realm, String role)
{
return roleRepo.findByNameAndRealmOptional(role, realm).orElse(new RoleEntity().setId(UUID.randomUUID().toString()).setName(role).setRealm(realm));
}
}

View File

@ -20,8 +20,8 @@ public class SuperuserBootstrapper
public void bootstrap()
{
Config config = ConfigProvider.getConfig();
String username = config.getValue("dev.dinauer.idp.superuser.username", String.class);
String password = config.getValue("dev.dinauer.idp.superuser.password", String.class);
String username = config.getValue("io.verifoo.superuser.username", String.class);
String password = config.getValue("io.verifoo.superuser.password", String.class);
if (!StringUtils.isBlank(username) && !StringUtils.isBlank(password) && superuserRepo.count() == 0)
{
SuperuserEntity superuser = new SuperuserEntity();

View File

@ -0,0 +1,48 @@
package de.tavolio.bootstrap;
import de.tavolio.realm.user.UserEntity;
import de.tavolio.realm.user.UserRepo;
import de.tavolio.realm.user.UserStatus;
import de.tavolio.bootstrap.model.User;
import de.tavolio.realm.RealmEntity;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.apache.commons.lang3.StringUtils;
import org.apache.commons.lang3.Strings;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@ApplicationScoped
public class UserBootstrapper
{
@Inject
UserRepo userRepo;
public void bootstrap(RealmEntity realm, List<User> users)
{
for (User user : users)
{
run(realm, user);
}
}
public void run(RealmEntity realm, User user)
{
Optional<UserEntity> existingAccount = userRepo.findOptionalByRealmAndEmail(realm, user.email());
if (existingAccount.isEmpty())
{
UserEntity newAccount = new UserEntity();
newAccount.setId(UUID.randomUUID().toString());
newAccount.setEmail(user.email());
newAccount.setFirstname(user.firstname());
newAccount.setLastname(user.lastname());
newAccount.setRealm(realm);
newAccount.setStatus(UserStatus.INIT);
newAccount.setPassword(Credentials.resolve(user.password()));
userRepo.persist(newAccount);
}
}
}

View File

@ -1,7 +0,0 @@
package de.tavolio.bootstrap.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record Account(String email, @JsonProperty("first-name") String firstname, @JsonProperty("last-name") String lastname, @JsonProperty("password-env") String passwordFromEnv, @JsonProperty("password-plain") String passwordPlain)
{
}

View File

@ -1,9 +1,15 @@
package de.tavolio.bootstrap.model;
import com.fasterxml.jackson.annotation.JsonProperty;
import de.tavolio.realm.user.Permission;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
public record Client(@JsonProperty("client-secret") String clientSecret, @JsonProperty("redirect-uri") String redirectURI, List<String> roles)
public record Client(@JsonProperty("secret") Credential secret, @JsonProperty("redirect-uri") String redirectURI, @JsonProperty("permissions") Set<String> permissionList)
{
public Set<Permission> permissions()
{
return permissionList.stream().map(item -> Permission.valueOf(item.toUpperCase())).collect(Collectors.toSet());
}
}

View File

@ -0,0 +1,5 @@
package de.tavolio.bootstrap.model;
public record Credential(String plain, String bcrypt, String env)
{
}

View File

@ -3,6 +3,6 @@ package de.tavolio.bootstrap.model;
import java.util.List;
import java.util.Map;
public record Realm(String name, Key key, Audience audience, Map<String, Client> clients, Map<String, Role> roles, List<String> permissions, List<Account> accounts)
public record Realm(String name, Key key, Audience audience, Map<String, Client> clients, Map<String, Role> roles, List<String> permissions, List<User> users)
{
}

View File

@ -0,0 +1,7 @@
package de.tavolio.bootstrap.model;
import com.fasterxml.jackson.annotation.JsonProperty;
public record User(String email, @JsonProperty("first-name") String firstname, @JsonProperty("last-name") String lastname, Credential password)
{
}

View File

@ -6,7 +6,7 @@ import org.eclipse.microprofile.config.inject.ConfigProperty;
@ApplicationScoped
public class IssuerService
{
@ConfigProperty(name = "dev.dinauer.idp.origin")
@ConfigProperty(name = "io.verifoo.http.origin")
String origin;
public String getIssuer(String realmKey)

View File

@ -16,7 +16,7 @@ public class OidcConfigurationResource
@Inject
IssuerService issuerService;
@ConfigProperty(name = "dev.dinauer.idp.origin")
@ConfigProperty(name = "io.verifoo.http.origin")
String origin;
@GET

View File

@ -1,9 +1,10 @@
package de.tavolio.oidc;
import de.tavolio.oidc.session.AuthorizationService;
import de.tavolio.oidc.session.dto.SessionCreation;
import de.tavolio.oidc.auth.AuthorizationService;
import de.tavolio.oidc.auth.model.AuthorizationCreation;
import de.tavolio.oidc.token.ClientTokenService;
import de.tavolio.oidc.token.model.TokenResponse;
import de.tavolio.oidc.token.TokenService;
import de.tavolio.oidc.token.UserTokenService;
import jakarta.annotation.security.RolesAllowed;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
@ -26,11 +27,14 @@ public class OidcResource
JwksService jwksService;
@Inject
TokenService tokenService;
UserTokenService userTokenService;
@Inject
AuthorizationService authorizationService;
@Inject
ClientTokenService clientTokenService;
@GET
@Path("/certs")
public Map<String, Object> certs()
@ -42,7 +46,7 @@ public class OidcResource
@Path("/auth")
public Response auth(@QueryParam("client_id") String clientId, @FormParam("email") String email, @FormParam("password") String password)
{
String code = authorizationService.generateBySessionCreation(realmKey, clientId, new SessionCreation(email, password));
String code = authorizationService.generateBySessionCreation(realmKey, clientId, new AuthorizationCreation(email, password));
return Response.status(302).location(URI.create("http://localhost:8080/callback?code=" + code + "&state=d")).build();
}
@ -50,15 +54,15 @@ public class OidcResource
@Path("/token")
@RolesAllowed("CLIENT")
@Transactional
public TokenResponse token(@FormParam("grant_type") String grantType, @FormParam("client_id") String clientId, @FormParam("code") String code) throws NoSuchAlgorithmException, InvalidKeySpecException
public TokenResponse token(@FormParam("grant_type") String grantType, @FormParam("code") String code) throws NoSuchAlgorithmException, InvalidKeySpecException
{
if (GrantType.AUTH_CODE.equals(GrantType.fromValue(grantType)))
{
return tokenService.getUserToken(realmKey, code);
return userTokenService.getToken(realmKey, code);
}
if (GrantType.CLIENT_CREDENTIALS.equals(GrantType.fromValue(grantType)))
{
return tokenService.getClientToken(realmKey);
return clientTokenService.getToken(realmKey);
}
throw new RuntimeException();
}

View File

@ -1,4 +1,4 @@
package de.tavolio.oidc.session;
package de.tavolio.oidc.auth;
import de.tavolio.realm.RealmService;
import de.tavolio.realm.user.UserEntity;
@ -8,7 +8,7 @@ import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.code.CodeEntity;
import de.tavolio.realm.code.CodeRepo;
import de.tavolio.realm.RealmEntity;
import de.tavolio.oidc.session.dto.SessionCreation;
import de.tavolio.oidc.auth.model.AuthorizationCreation;
import io.quarkus.elytron.security.common.BcryptUtil;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
@ -35,16 +35,16 @@ public class AuthorizationService
ClientService clientService;
@Transactional
public String generateBySessionCreation(String realmKey, String clientId, SessionCreation sessionCreation)
public String generateBySessionCreation(String realmKey, String clientId, AuthorizationCreation authorizationCreation)
{
RealmEntity realm = realmService.requireByKey(realmKey);
ClientEntity client = clientService.findByIdAndRealm(clientId, realm);
Optional<UserEntity> accountEntityOptional = userRepo.findOptionalByRealmAndEmail(realm, sessionCreation.email());
Optional<UserEntity> accountEntityOptional = userRepo.findOptionalByRealmAndEmail(realm, authorizationCreation.email());
if (accountEntityOptional.isPresent())
{
UserEntity userEntity = accountEntityOptional.get();
if (BcryptUtil.matches(sessionCreation.password(), userEntity.getPassword()))
if (BcryptUtil.matches(authorizationCreation.password(), userEntity.getPassword()))
{
CodeEntity code = new CodeEntity().setId(UUID.randomUUID().toString());
code.setExpiresAt(ZonedDateTime.now().plusMinutes(1));

View File

@ -1,9 +1,9 @@
package de.tavolio.oidc.session.dto;
package de.tavolio.oidc.auth.model;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
public record SessionCreation(
public record AuthorizationCreation(
@Email String email,
@NotBlank String password)
{

View File

@ -1,4 +1,4 @@
package de.tavolio.oidc.auth;
package de.tavolio.oidc.identityproviders;
import de.tavolio.Role;
import de.tavolio.realm.user.UserRepo;
@ -16,20 +16,20 @@ import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
@ApplicationScoped
public class OidcClientIdentityProvider implements IdentityProvider<UsernamePasswordAuthenticationRequest>
@Priority(1)
public class BasicAuthIdentityProvider implements IdentityProvider<UsernamePasswordAuthenticationRequest>
{
@Inject
ClientRepo clientRepo;
@Inject
SuperuserRepo superuserRepo;
@Inject
UserRepo userRepo;
@Override
public Class<UsernamePasswordAuthenticationRequest> getRequestType()

View File

@ -0,0 +1,129 @@
package de.tavolio.oidc.identityproviders;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.tavolio.Role;
import de.tavolio.oidc.IssuerService;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.client.ClientEntity;
import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.key.KeypairEntity;
import de.tavolio.realm.key.KeypairRepo;
import de.tavolio.verify.JwksService;
import de.tavolio.verify.jwks.JwksKey;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.StringPermission;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.jwt.auth.principal.DefaultJWTParser;
import io.smallrye.jwt.auth.principal.JWTAuthContextInfo;
import io.smallrye.jwt.auth.principal.ParseException;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.annotation.Priority;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.security.Permission;
import java.security.PublicKey;
import java.util.*;
import java.util.stream.Collectors;
@ApplicationScoped
@Priority(1)
public class ClientIdentityProvider implements IdentityProvider<TokenAuthenticationRequest>
{
private static final Logger LOG = LoggerFactory.getLogger(ClientIdentityProvider.class);
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Inject
JwksService jwksService;
@Inject
KeypairRepo keypairRepo;
@Inject
IssuerService issuerService;
@Inject
ClientService clientService;
@Override
public Class<TokenAuthenticationRequest> getRequestType()
{
return TokenAuthenticationRequest.class;
}
@Override
@ActivateRequestContext
public Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest tokenAuthenticationRequest, AuthenticationRequestContext authenticationRequestContext)
{
String raw = tokenAuthenticationRequest.getToken().getToken();
try
{
Object kid = getHeader(raw).get("kid");
if (kid instanceof String keyId)
{
return Uni.createFrom().item(() -> {
KeypairEntity keypairEntity = keypairRepo.findById(keyId);
if (keypairEntity != null)
{
PublicKey publicKey = jwksService.findByKid(keypairEntity).toPublicKey();
RealmEntity realm = keypairEntity.getRealm();
try
{
JsonWebToken token = new DefaultJWTParser(getContextForRealm(realm.getKey())).verify(raw, publicKey);
ClientEntity client = clientService.findByIdAndRealm(token.getName(), realm);
if (client != null)
{
return (SecurityIdentity) QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal(client.getId())).addRole(Role.CLIENT.toString()).addAttribute("permissions", new HashSet<>(client.getPermissions())).build();
}
}
catch (ParseException e)
{
throw new RuntimeException(e);
}
}
LOG.error("Cannot find key with id {}", kid);
throw new AuthenticationFailedException();
}).runSubscriptionOn(Infrastructure.getDefaultWorkerPool());
}
return Uni.createFrom().nullItem();
}
catch (JsonProcessingException e)
{
return Uni.createFrom().failure(new AuthenticationFailedException());
}
}
private Map<String, Object> getHeader(String raw) throws JsonProcessingException
{
return OBJECT_MAPPER.readValue(new String(Base64.getUrlDecoder().decode(section(raw))), new TypeReference<Map<String, Object>>(){});
}
private String section(String raw)
{
String[] sections = raw.split("\\.");
if (sections.length == 3)
{
return sections[0];
}
throw new RuntimeException();
}
private JWTAuthContextInfo getContextForRealm(String realmKey)
{
JWTAuthContextInfo info = new JWTAuthContextInfo();
info.setIssuedBy(issuerService.getIssuer(realmKey));
return info;
}
}

View File

@ -0,0 +1,29 @@
package de.tavolio.oidc.token;
import de.tavolio.oidc.IssuerService;
import io.smallrye.jwt.build.Jwt;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import java.security.PrivateKey;
import java.time.ZonedDateTime;
@ApplicationScoped
public class ClientTokenGenerator
{
@Inject
IssuerService issuerService;
public String generateAccessToken(String realmKey, String clientId, ZonedDateTime expiresAt, PrivateKey key, String keyId)
{
return Jwt.claims()
.upn(clientId)
.audience(clientId)
.subject(clientId)
.claim("realm_key", realmKey)
.claim("client_id", clientId)
.expiresAt(expiresAt.toInstant())
.issuer(issuerService.getIssuer(realmKey)).jws().keyId(keyId)
.sign(key);
}
}

View File

@ -0,0 +1,48 @@
package de.tavolio.oidc.token;
import de.tavolio.oidc.token.model.TokenResponse;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmService;
import de.tavolio.realm.client.ClientEntity;
import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.key.KeypairEntity;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.BadRequestException;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.time.ZonedDateTime;
@ApplicationScoped
public class ClientTokenService
{
@Inject
RealmService realmService;
@Inject
ClientService clientService;
@Inject
SecurityIdentity identity;
@Inject
ClientTokenGenerator clientTokenGenerator;
public TokenResponse getToken(String realmKey)
{
RealmEntity realm = realmService.requireByKey(realmKey);
ClientEntity client = clientService.findByIdAndRealm(identity.getPrincipal().getName(), realm);
if (client != null)
{
KeypairEntity keypair = realm.getKeys().getFirst();
PrivateKey signingKey = KeypairEntity.toPrivateKey(keypair);
ZonedDateTime expiresAt = ZonedDateTime.now().plusYears(1);
String token = clientTokenGenerator.generateAccessToken(realmKey, client.getId(), expiresAt, signingKey, keypair.getId());
return new TokenResponse().setAccessToken(token).setTokenType("Bearer").setExpiresAt(expiresAt.toInstant().getEpochSecond());
}
throw new BadRequestException();
}
}

View File

@ -9,7 +9,7 @@ import java.security.PrivateKey;
import java.time.ZonedDateTime;
@ApplicationScoped
public class TokenGenerator
public class UserTokenGenerator
{
@Inject
IssuerService issuerService;

View File

@ -2,6 +2,8 @@ package de.tavolio.oidc.token;
import de.tavolio.oidc.token.model.TokenResponse;
import de.tavolio.realm.RealmRepo;
import de.tavolio.realm.client.ClientEntity;
import de.tavolio.realm.client.ClientService;
import de.tavolio.realm.code.CodeEntity;
import de.tavolio.realm.code.CodeRepo;
import de.tavolio.realm.RealmEntity;
@ -11,41 +13,31 @@ import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.*;
import jakarta.ws.rs.core.Context;
import org.apache.commons.lang3.NotImplementedException;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.PublicKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
import java.time.ZonedDateTime;
import java.util.Set;
import java.util.UUID;
@ApplicationScoped
public class TokenService
public class UserTokenService
{
@Inject
CodeRepo codeRepo;
@Inject
TokenGenerator tokenGenerator;
UserTokenGenerator userTokenGenerator;
@Inject
SecurityIdentity identity;
@Inject
RealmRepo realmRepo;
RealmService realmService;
@POST
public TokenResponse getClientToken(String realmKey)
{
return null;
}
public TokenResponse getUserToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException
public TokenResponse getToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException
{
return generateUserToken(realmKey, code);
}
@ -53,18 +45,17 @@ public class TokenService
private TokenResponse generateUserToken(String realmKey, String code) throws NoSuchAlgorithmException, InvalidKeySpecException
{
String principal = identity.getPrincipal().getName();
RealmEntity realm = realmRepo.findById(realmKey);
RealmEntity realm = realmService.requireByKey(realmKey);
CodeEntity entity = codeRepo.findByRealmAndId(realm, code);
if (entity != null && !ZonedDateTime.now().isAfter(entity.getExpiresAt()) && principal.equals(entity.getClient().getId()))
{
KeypairEntity keypair = realm.getKeys().getFirst();
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keypair.getPrivateKey());
PrivateKey signingKey = KeypairEntity.toPrivateKey(keypair);
ZonedDateTime expiresAt = ZonedDateTime.now().plusYears(1);
PrivateKey signingKey = KeyFactory.getInstance(RealmService.EC).generatePrivate(spec);
TokenResponse response = new TokenResponse()
.setAccessToken(tokenGenerator.generateAccessToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId()))
.setAccessToken(userTokenGenerator.generateAccessToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId()))
.setRefreshToken(UUID.randomUUID().toString())
.setIdToken(tokenGenerator.generateIDToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId()))
.setIdToken(userTokenGenerator.generateIDToken(realm.getKey(), principal, entity.getAccount().getId(), expiresAt, signingKey, keypair.getId()))
.setTokenType("Bearer")
.setExpiresAt(expiresAt.toInstant().getEpochSecond());
codeRepo.delete(entity);

View File

@ -0,0 +1,34 @@
package de.tavolio.realm;
import de.tavolio.Role;
import de.tavolio.realm.user.Permission;
import io.quarkus.security.ForbiddenException;
import io.quarkus.security.identity.SecurityIdentity;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import java.util.Set;
@RequestScoped
public class PermissionService
{
@Inject
SecurityIdentity identity;
public void hasPermission(Permission... required)
{
if (identity.hasRole(Role.ROOT.toString()))
{
return;
}
Set<Permission> granted = identity.getAttribute("permissions");
for (Permission permission : required)
{
if (granted != null && granted.contains(permission))
{
return;
}
}
throw new ForbiddenException();
}
}

View File

@ -0,0 +1,5 @@
package de.tavolio.realm;
public record Realm(String key, String name)
{
}

View File

@ -9,6 +9,12 @@ import jakarta.ws.rs.Path;
@Path("/realms/{realm-key}")
public class RealmResource
{
@Path("/")
public RealmSubResource realms()
{
return CDI.current().select(RealmSubResource.class).get();
}
@Path("/accounts")
public UserResource accounts()
{

View File

@ -0,0 +1,25 @@
package de.tavolio.realm;
import jakarta.enterprise.context.RequestScoped;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.NotFoundException;
import jakarta.ws.rs.PathParam;
@RequestScoped
public class RealmSubResource
{
@Inject
RealmRepo repo;
@GET
public Realm get(@PathParam("realm-key") String key)
{
RealmEntity entity = repo.findById(key);
if (entity != null)
{
return new Realm(entity.getKey(), entity.getName());
}
throw new NotFoundException();
}
}

View File

@ -3,9 +3,13 @@ package de.tavolio.realm.client;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmScoped;
import de.tavolio.realm.code.CodeEntity;
import de.tavolio.realm.user.Permission;
import jakarta.persistence.*;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
@Entity
@Table(name = "client")
@ -26,6 +30,14 @@ public class ClientEntity implements RealmScoped
@OneToMany(mappedBy = "client")
private List<CodeEntity> codes;
@ElementCollection
@CollectionTable(
name = "client_permission",
joinColumns = @JoinColumn(name = "client_id")
)
@Column(name = "permission")
private Set<Permission> permissions = new HashSet<>();
public String getId()
{
return id;
@ -80,4 +92,15 @@ public class ClientEntity implements RealmScoped
this.codes = codes;
return this;
}
public Set<Permission> getPermissions()
{
return permissions;
}
public ClientEntity setPermissions(Set<Permission> permissions)
{
this.permissions = permissions;
return this;
}
}

View File

@ -1,5 +1,6 @@
package de.tavolio.realm.client;
import de.tavolio.bootstrap.Credentials;
import de.tavolio.bootstrap.model.Client;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmRepo;
@ -21,16 +22,6 @@ public class ClientService
@Inject
RealmRepo realmRepo;
public ClientEntity findOrCreate(RealmEntity realm, Map.Entry<String, Client> bootstrap)
{
String secret = null;
if (!StringUtils.isBlank(bootstrap.getValue().clientSecret()))
{
secret = BcryptUtil.bcryptHash(System.getenv(bootstrap.getValue().clientSecret()));
}
return clientRepo.findByRealmAndIdOptional(realm, bootstrap.getKey()).orElse(new ClientEntity().setId(bootstrap.getKey()).setSecret(secret).setRedirectURI(bootstrap.getValue().redirectURI()).setRealm(realm));
}
public ClientEntity findByIdAndRealm(String clientId, RealmEntity realm)
{
ClientEntity client = clientRepo.findByRealmAndId(realm, clientId);

View File

@ -2,8 +2,15 @@ package de.tavolio.realm.key;
import de.tavolio.realm.RealmEntity;
import de.tavolio.realm.RealmScoped;
import de.tavolio.realm.RealmService;
import jakarta.persistence.*;
import java.security.KeyFactory;
import java.security.NoSuchAlgorithmException;
import java.security.PrivateKey;
import java.security.spec.InvalidKeySpecException;
import java.security.spec.PKCS8EncodedKeySpec;
@Entity
@Table(name = "keypair")
public class KeypairEntity implements RealmScoped
@ -128,4 +135,17 @@ public class KeypairEntity implements RealmScoped
this.y = y;
return this;
}
public static PrivateKey toPrivateKey(KeypairEntity keypair)
{
PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keypair.getPrivateKey());
try
{
return KeyFactory.getInstance(RealmService.EC).generatePrivate(spec);
}
catch (InvalidKeySpecException | NoSuchAlgorithmException e)
{
throw new RuntimeException(e);
}
}
}

View File

@ -51,8 +51,8 @@ public class KeypairService
.setUse("sig")
.setAlg("ES256")
.setCrv("P-256")
.setX(Base64.getUrlEncoder().withoutPadding().encodeToString(xBytes))
.setY(Base64.getUrlEncoder().withoutPadding().encodeToString(yBytes));
.setX(Base64.getEncoder().withoutPadding().encodeToString(xBytes))
.setY(Base64.getEncoder().withoutPadding().encodeToString(yBytes));
}
private KeyPair generate()

View File

@ -0,0 +1,6 @@
package de.tavolio.realm.user;
public enum Permission
{
USER_VIEW, USER_CREATE
}

View File

@ -1,5 +1,6 @@
package de.tavolio.realm.user;
import de.tavolio.realm.PermissionService;
import de.tavolio.realm.user.dto.User;
import de.tavolio.realm.user.dto.UserCreation;
import jakarta.enterprise.context.RequestScoped;
@ -20,27 +21,34 @@ public class UserResource
@Inject
Logger LOG;
@PathParam("realm-key")
String realmKey;
@Inject
UserService userService;
@Inject
PermissionService permissionService;
@POST
public User post(@Valid UserCreation account)
{
User createdUser = userService.create("", account);
LOG.infof("Created account successfully: %s", account.email());
return createdUser;
permissionService.hasPermission(Permission.USER_CREATE);
return userService.create(realmKey, account);
}
@GET
public User get(@PathParam("id") String id)
public List<User> get()
{
return userService.getUser(id);
permissionService.hasPermission(Permission.USER_VIEW);
return userService.get();
}
@GET
@Path("/{id}")
public User getById(@PathParam("id") String id)
{
permissionService.hasPermission(Permission.USER_VIEW);
return userService.getUser(id);
}
@ -48,6 +56,7 @@ public class UserResource
@Path("/search")
public Map<String, User> get(List<String> ids)
{
permissionService.hasPermission(Permission.USER_VIEW);
return userService.findByIds(ids);
}
}

View File

@ -7,6 +7,7 @@ import de.tavolio.verify.jwks.EcPublicKey;
import de.tavolio.verify.jwks.JwksKey;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.inject.Inject;
import org.apache.commons.lang3.NotImplementedException;
import java.util.LinkedList;
import java.util.List;
@ -18,45 +19,24 @@ public class JwksService
@Inject
KeypairRepo keypairRepo;
public Optional<JwksKey> findByKid(String kid)
{
KeypairEntity keypair = keypairRepo.findById(kid);
if (keypair != null)
public JwksKey findByKid(KeypairEntity keypair)
{
switch (keypair.getType())
{
case "EC" ->
{
return Optional.of(constructPublicKey(keypair));
return constructPublicKey(keypair);
}
case "RSA" ->
{
throw new NotImplementedException();
}
default ->
{
throw new IllegalArgumentException();
}
}
}
return Optional.empty();
}
public List<JwksKey> findByRealm(RealmEntity realm)
{
List<JwksKey> result = new LinkedList<>();
for (KeypairEntity keypair : realm.getKeys())
{
switch (keypair.getType())
{
case "EC" ->
{
result.add(constructPublicKey(keypair));
}
case "RSA" ->
{
throw new IllegalArgumentException();
}
}
}
return result;
}
private EcPublicKey constructPublicKey(KeypairEntity entity)
{

View File

@ -1,81 +0,0 @@
package de.tavolio.verify;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.tavolio.verify.jwks.JwksKey;
import io.quarkus.security.AuthenticationFailedException;
import io.quarkus.security.identity.AuthenticationRequestContext;
import io.quarkus.security.identity.IdentityProvider;
import io.quarkus.security.identity.SecurityIdentity;
import io.quarkus.security.identity.request.TokenAuthenticationRequest;
import io.quarkus.security.runtime.QuarkusPrincipal;
import io.quarkus.security.runtime.QuarkusSecurityIdentity;
import io.smallrye.mutiny.Uni;
import io.smallrye.mutiny.infrastructure.Infrastructure;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.enterprise.context.control.ActivateRequestContext;
import jakarta.inject.Inject;
import java.security.PublicKey;
import java.util.Base64;
import java.util.Map;
import java.util.Optional;
@ApplicationScoped
public class TokenVerifier implements IdentityProvider<TokenAuthenticationRequest>
{
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Inject
JwksService jwksService;
@Override
public Class<TokenAuthenticationRequest> getRequestType()
{
return TokenAuthenticationRequest.class;
}
@Override
@ActivateRequestContext
public Uni<SecurityIdentity> authenticate(TokenAuthenticationRequest tokenAuthenticationRequest, AuthenticationRequestContext authenticationRequestContext)
{
String raw = tokenAuthenticationRequest.getToken().getToken();
try
{
Object kid = getHeader(raw).get("kid");
if (kid instanceof String keyId)
{
return Uni.createFrom().item(() -> {
Optional<JwksKey> keypair = jwksService.findByKid(keyId);
if (keypair.isPresent())
{
PublicKey publicKey = keypair.get().toPublicKey();
return (SecurityIdentity) QuarkusSecurityIdentity.builder().setPrincipal(new QuarkusPrincipal("")).build();
}
throw new AuthenticationFailedException();
}).runSubscriptionOn(Infrastructure.getDefaultWorkerPool());
}
return Uni.createFrom().nullItem();
}
catch (JsonProcessingException e)
{
return Uni.createFrom().failure(new AuthenticationFailedException());
}
}
private Map<String, Object> getHeader(String raw) throws JsonProcessingException
{
return OBJECT_MAPPER.readValue(new String(Base64.getDecoder().decode(section(raw))), new TypeReference<Map<String, Object>>(){});
}
private String section(String raw)
{
String[] sections = raw.split("\\.");
if (sections.length == 3)
{
return sections[0];
}
throw new RuntimeException();
}
}

View File

@ -6,15 +6,6 @@ quarkus.http.test-port=9089
quarkus.http.cors.enabled=true
%dev.quarkus.http.cors.origins=/.*/
# JWT
%prod.smallrye.jwt.sign.key.location=${PRIVATE_KEY_LOCATION}
%prod.mp.jwt.verify.publickey.location=${PUBLIC_KEY_LOCATION}
mp.jwt.verify.issuer=https://tavolio.de
%dev.smallrye.jwt.verify.key.location=/home/andreas/Documents/dev/publicKey.pem
%dev.smallrye.jwt.sign.key.location=/home/andreas/Documents/dev/privateKey.pem
# Postgres
prod.quarkus.hibernate-orm.validate-in-dev-mode=false
quarkus.hibernate-orm.schema-management.strategy=none
@ -25,10 +16,6 @@ quarkus.datasource.db-kind=postgresql
%dev,test.quarkus.datasource.password=postgres
%dev,test.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres?currentSchema=auth
%prod.quarkus.datasource.username=${DB_USER}
%prod.quarkus.datasource.password=${DB_PASSWORD}
%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_DATABASE}?currentSchema=${DB_SCHEMA}
# Flyway
quarkus.flyway.enabled=false
%test.quarkus.flyway.clean-at-start=true
@ -37,16 +24,10 @@ quarkus.flyway.enabled=false
%test,dev.quarkus.flyway.migrate-at-start=false
quarkus.flyway.migrate-at-start=true
# IAM Superuser
%test,dev.iam.user.name=tavolio
%test,dev.iam.user.password=tavolio
quarkus.http.access-log.enabled=true
quarkus.http.auth.basic=true
dev.dinauer.idp.origin=http://localhost:8089
%dev.dev.dinauer.idp.superuser.username=admin
%dev.dev.dinauer.idp.superuser.password=pw
io.verifoo.http.origin=http://localhost:8089
quarkus.log.level=DEBUG
%dev.io.verifoo.superuser.username=admin
%dev.io.verifoo.superuser.password=pw

View File

@ -1,22 +1,22 @@
realms:
maven:
name: My Bootstrap Realm
name: MavenVault
audience:
strategy: realm
strategy: static
value: https://maven.dinauer.dev/api
key:
type: EC
alg: P256
clients:
backend:
client-secret: MY_SECRET_ENV
permissions:
- USER:VIEW
frontend:
secret:
bcrypt: $2a$12$1oYS45e/nXP1OeMgdZZAKeEixarRDzbBGZd0xOnEQQMKlOKwVMrX.
redirect-uri: http://localhost:8080/callback
accounts:
permissions:
- USER_VIEW
users:
- email: andreas.j.dinauer@gmail.com
first-name: Andreas
last-name: Dinauer
password-plain: pw
roles:
- USER:VIEW
password:
bcrypt: $2a$12$vvHIfs6MWovIulGcuHeDie7yKtZzkBEPpNPiuj3C2HmUASYQ/x5SO