diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100755 index 0000000..18e73b4 --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,47 @@ +pipeline { + agent any + + stages { + stage('Set Image Name') { + steps { + script { + env.TAG = "${env.BUILD_NUMBER}" + env.REFERENCE = "harbor.dinauer.dev/tavolio/iam-backend" + env.IMAGE = "${env.REFERENCE}:${env.BUILD_NUMBER}"; + } + } + } + stage('Build Quarkus application') { + steps { + script { + sh './gradlew build' + } + } + } + stage('Build Docker Image') { + steps { + script { + sh "docker build --no-cache -t ${env.IMAGE} -f src/main/docker/Dockerfile.jvm ." + } + } + } + stage('Push Image to Docker Hub') { + steps { + script { + withCredentials([usernamePassword(credentialsId: 'harbor', usernameVariable: 'USERNAME', passwordVariable: 'PASSWORD')]) { + sh 'echo ${PASSWORD} | docker login harbor.dinauer.dev -u ${USERNAME} --password-stdin' + sh "docker push ${env.IMAGE}" + sh "docker logout" + } + } + } + } + stage('Remove image from host') { + steps { + script { + sh "docker image rm --force ${env.IMAGE}" + } + } + } + } +} \ No newline at end of file diff --git a/pom.xml b/pom.xml index 3ddec8c..9105a2d 100644 --- a/pom.xml +++ b/pom.xml @@ -63,6 +63,14 @@ io.quarkus quarkus-hibernate-validator + + io.quarkus + quarkus-smallrye-jwt + + + io.quarkus + quarkus-smallrye-jwt-build + io.quarkus quarkus-junit5 diff --git a/src/main/java/de/tavolio/AuthenticationService.java b/src/main/java/de/tavolio/AuthenticationService.java new file mode 100644 index 0000000..57a893d --- /dev/null +++ b/src/main/java/de/tavolio/AuthenticationService.java @@ -0,0 +1,93 @@ +package de.tavolio; + +import de.tavolio.account.AccountEntity; +import de.tavolio.account.AccountRepo; +import io.quarkus.security.UnauthorizedException; +import io.vertx.core.http.HttpServerRequest; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.SecurityContext; +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.jboss.logging.Logger; + +import java.security.Principal; +import java.util.Base64; + +@ApplicationScoped +public class AuthenticationService +{ + @Inject + Logger LOG; + + @Inject + AccountRepo accountRepo; + + @Inject + SecurityContext securityContext; + + @Inject + HttpServerRequest request; + + @ConfigProperty(name = "iam.user.name") + String superuserName; + + @ConfigProperty(name = "iam.user.password") + String superuserPassword; + + public boolean isSuperUser() + { + String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION); + if (authHeader != null && !authHeader.isBlank()) + { + String[] sections = authHeader.split("\\s+"); + if (sections.length == 2 && sections[0].equals("Basic")) + { + String value = new String(Base64.getDecoder().decode(sections[1])); + String[] parts = value.split(":"); + if (parts.length == 2) + { + String username = parts[0]; + String password = parts[1]; + boolean isSuperuser = username.equals(superuserName) && password.equals(superuserPassword); + if (isSuperuser) + { + return true; + } + { + LOG.errorf("Invalid username or password", getRequestPath()); + return false; + } + } + else + { + LOG.errorf("Invalid base64 credentials %s", getRequestPath()); + } + } + } + return false; + } + + public AccountEntity requireUser() + { + Principal principal = securityContext.getUserPrincipal(); + if(principal != null) + { + AccountEntity accountEntity = accountRepo.findById(principal.getName()); + if(accountEntity != null) + { + return accountEntity; + } + LOG.warnf("No account found for request %s", getRequestPath()); + throw new NotFoundException(); + } + LOG.warnf("Unauthorized request %s", getRequestPath()); + throw new UnauthorizedException(); + } + + private String getRequestPath() + { + return String.format("[%s, %s]", request.method().name(), request.path()); + } +} diff --git a/src/main/java/de/tavolio/account/AccountEntity.java b/src/main/java/de/tavolio/account/AccountEntity.java index aa2c979..c061939 100644 --- a/src/main/java/de/tavolio/account/AccountEntity.java +++ b/src/main/java/de/tavolio/account/AccountEntity.java @@ -1,10 +1,10 @@ package de.tavolio.account; +import de.tavolio.member.MembershipEntity; import io.quarkus.hibernate.orm.panache.PanacheEntityBase; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.Table; +import jakarta.persistence.*; +import java.util.Set; import java.util.UUID; @Entity @@ -22,6 +22,12 @@ public class AccountEntity extends PanacheEntityBase private String password; + @Enumerated(EnumType.STRING) + private AccountStatus status; + + @OneToMany(mappedBy = "account") + private Set memberships; + public static AccountEntity init() { return new AccountEntity().setId(UUID.randomUUID().toString()); @@ -81,4 +87,26 @@ public class AccountEntity extends PanacheEntityBase this.password = password; return this; } + + public Set getMemberships() + { + return memberships; + } + + public AccountEntity setMemberships(Set memberships) + { + this.memberships = memberships; + return this; + } + + public AccountStatus getStatus() + { + return status; + } + + public AccountEntity setStatus(AccountStatus status) + { + this.status = status; + return this; + } } diff --git a/src/main/java/de/tavolio/account/AccountMapper.java b/src/main/java/de/tavolio/account/AccountMapper.java index e8a2b29..bf8c58c 100644 --- a/src/main/java/de/tavolio/account/AccountMapper.java +++ b/src/main/java/de/tavolio/account/AccountMapper.java @@ -2,13 +2,12 @@ package de.tavolio.account; import de.tavolio.account.dto.Account; import jakarta.enterprise.context.ApplicationScoped; -import jakarta.ws.rs.ApplicationPath; @ApplicationScoped public class AccountMapper { public Account map(AccountEntity accountEntity) { - return new Account(accountEntity.getId()); + return new Account(accountEntity.getId(), accountEntity.getFirstname(), accountEntity.getLastname(), accountEntity.getEmail(), accountEntity.getStatus()); } } diff --git a/src/main/java/de/tavolio/account/AccountRepo.java b/src/main/java/de/tavolio/account/AccountRepo.java index a172cc9..3580a12 100644 --- a/src/main/java/de/tavolio/account/AccountRepo.java +++ b/src/main/java/de/tavolio/account/AccountRepo.java @@ -1,9 +1,16 @@ package de.tavolio.account; import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; import jakarta.enterprise.context.ApplicationScoped; +import java.util.Optional; + @ApplicationScoped public class AccountRepo implements PanacheRepositoryBase { + public Optional findOptionalByEmail(String email) + { + return find("email = :email", Parameters.with("email", email)).firstResultOptional(); + } } diff --git a/src/main/java/de/tavolio/account/AccountResource.java b/src/main/java/de/tavolio/account/AccountResource.java index 0fd1484..3cb9b27 100644 --- a/src/main/java/de/tavolio/account/AccountResource.java +++ b/src/main/java/de/tavolio/account/AccountResource.java @@ -7,8 +7,8 @@ import jakarta.validation.Valid; import jakarta.ws.rs.GET; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; +import jakarta.ws.rs.PathParam; import org.jboss.logging.Logger; -import org.jboss.resteasy.reactive.common.NotImplementedYet; @Path("/accounts") public class AccountResource @@ -28,8 +28,9 @@ public class AccountResource } @GET - public Account get() + @Path("/{id}") + public Account get(@PathParam("id") String id) { - throw new NotImplementedYet(); + return accountService.getUser(id); } } diff --git a/src/main/java/de/tavolio/account/AccountService.java b/src/main/java/de/tavolio/account/AccountService.java index d5db232..61577d5 100644 --- a/src/main/java/de/tavolio/account/AccountService.java +++ b/src/main/java/de/tavolio/account/AccountService.java @@ -1,21 +1,30 @@ package de.tavolio.account; +import de.tavolio.AuthenticationService; import de.tavolio.account.dto.Account; import de.tavolio.account.dto.AccountCreation; import io.quarkus.elytron.security.common.BcryptUtil; +import io.quarkus.security.UnauthorizedException; import jakarta.enterprise.context.ApplicationScoped; import jakarta.inject.Inject; import jakarta.transaction.Transactional; +import org.jboss.logging.Logger; @ApplicationScoped public class AccountService { + @Inject + Logger LOG; + @Inject AccountRepo accountRepo; @Inject AccountMapper accountMapper; + @Inject + AuthenticationService authenticationService; + @Transactional public Account create(AccountCreation account) { @@ -23,8 +32,21 @@ public class AccountService accountEntity.setEmail(account.email()) .setFirstname(account.firstname()) .setLastname(account.lastname()) - .setPassword(BcryptUtil.bcryptHash(account.password())); + .setPassword(BcryptUtil.bcryptHash(account.password())) + .setStatus(AccountStatus.INIT); accountRepo.persist(accountEntity); return accountMapper.map(accountEntity); } + + public Account getUser(String id) + { + AccountEntity account = authenticationService.requireUser(); + AccountEntity requestedAccount = accountRepo.findById(id); + if (requestedAccount != null && requestedAccount.getId().equals(account.getId())) + { + return accountMapper.map(authenticationService.requireUser()); + } + LOG.errorf("Cannot access account"); + throw new UnauthorizedException(); + } } diff --git a/src/main/java/de/tavolio/account/AccountStatus.java b/src/main/java/de/tavolio/account/AccountStatus.java new file mode 100644 index 0000000..ec92f05 --- /dev/null +++ b/src/main/java/de/tavolio/account/AccountStatus.java @@ -0,0 +1,6 @@ +package de.tavolio.account; + +public enum AccountStatus +{ + INIT, REGISTERED +} diff --git a/src/main/java/de/tavolio/account/dto/Account.java b/src/main/java/de/tavolio/account/dto/Account.java index 9d9ba1f..1398fb2 100644 --- a/src/main/java/de/tavolio/account/dto/Account.java +++ b/src/main/java/de/tavolio/account/dto/Account.java @@ -1,5 +1,7 @@ package de.tavolio.account.dto; -public record Account(String id) +import de.tavolio.account.AccountStatus; + +public record Account(String id, String firstname, String lastname, String email, AccountStatus status) { } diff --git a/src/main/java/de/tavolio/member/AccountMembershipResource.java b/src/main/java/de/tavolio/member/AccountMembershipResource.java new file mode 100644 index 0000000..00d942e --- /dev/null +++ b/src/main/java/de/tavolio/member/AccountMembershipResource.java @@ -0,0 +1,33 @@ +package de.tavolio.member; + +import de.tavolio.AuthenticationService; +import de.tavolio.account.AccountEntity; +import de.tavolio.member.dto.AccountMemberships; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.GET; +import jakarta.ws.rs.Path; + +@ApplicationScoped +@Path("/memberships") +public class AccountMembershipResource +{ + @Inject + MembershipRepo membershipRepo; + + @Inject + AuthenticationService authenticationService; + + @Inject + MembershipMapper membershipMapper; + + @GET + public AccountMemberships get() + { + AccountEntity account = authenticationService.requireUser(); + return new AccountMemberships( + membershipMapper.map(membershipRepo.findByTenantTypeAndAccount(TenantType.ORGANISATION, account)), + membershipMapper.map(membershipRepo.findByTenantTypeAndAccount(TenantType.RESTAURANT, account)) + ); + } +} diff --git a/src/main/java/de/tavolio/member/MembershipEntity.java b/src/main/java/de/tavolio/member/MembershipEntity.java new file mode 100644 index 0000000..dc0df40 --- /dev/null +++ b/src/main/java/de/tavolio/member/MembershipEntity.java @@ -0,0 +1,105 @@ +package de.tavolio.member; + +import de.tavolio.account.AccountEntity; +import io.quarkus.hibernate.orm.panache.PanacheEntityBase; +import jakarta.persistence.*; + +import java.time.ZonedDateTime; +import java.util.UUID; + +@Entity +@Table(name = "membership") +public class MembershipEntity extends PanacheEntityBase +{ + @Id + private String id; + + @Column(name = "tenant_type") + @Enumerated(EnumType.STRING) + private TenantType tenantType; + + @Column(name = "tenant_id") + private String tenantId; + + @Column(name = "member_role") + @Enumerated(EnumType.STRING) + private MembershipRole role; + + @Column(name = "member_since") + private ZonedDateTime memberSince; + + @ManyToOne + @JoinColumn(name = "account_id") + private AccountEntity account; + + public static MembershipEntity init() + { + return new MembershipEntity().setId(UUID.randomUUID().toString()); + } + + public String getId() + { + return id; + } + + public MembershipEntity setId(String id) + { + this.id = id; + return this; + } + + public TenantType getTenantType() + { + return tenantType; + } + + public MembershipEntity setTenantType(TenantType tenantType) + { + this.tenantType = tenantType; + return this; + } + + public String getTenantId() + { + return tenantId; + } + + public MembershipEntity setTenantId(String tenantId) + { + this.tenantId = tenantId; + return this; + } + + public MembershipRole getRole() + { + return role; + } + + public MembershipEntity setRole(MembershipRole role) + { + this.role = role; + return this; + } + + public ZonedDateTime getMemberSince() + { + return memberSince; + } + + public MembershipEntity setMemberSince(ZonedDateTime memberSince) + { + this.memberSince = memberSince; + return this; + } + + public AccountEntity getAccount() + { + return account; + } + + public MembershipEntity setAccount(AccountEntity account) + { + this.account = account; + return this; + } +} diff --git a/src/main/java/de/tavolio/member/MembershipMapper.java b/src/main/java/de/tavolio/member/MembershipMapper.java new file mode 100644 index 0000000..7071e29 --- /dev/null +++ b/src/main/java/de/tavolio/member/MembershipMapper.java @@ -0,0 +1,25 @@ +package de.tavolio.member; + +import de.tavolio.account.AccountMapper; +import de.tavolio.member.dto.Membership; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.util.List; + +@ApplicationScoped +public class MembershipMapper +{ + @Inject + AccountMapper accountMapper; + + public Membership map(MembershipEntity membership) + { + return new Membership(membership.getId(), membership.getTenantType(), membership.getTenantId(), membership.getRole(), accountMapper.map(membership.getAccount())); + } + + public List map(List memberships) + { + return memberships.stream().map(this::map).toList(); + } +} diff --git a/src/main/java/de/tavolio/member/MembershipRepo.java b/src/main/java/de/tavolio/member/MembershipRepo.java new file mode 100644 index 0000000..4143778 --- /dev/null +++ b/src/main/java/de/tavolio/member/MembershipRepo.java @@ -0,0 +1,22 @@ +package de.tavolio.member; + +import de.tavolio.account.AccountEntity; +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; +import io.quarkus.panache.common.Parameters; +import jakarta.enterprise.context.ApplicationScoped; + +import java.util.List; + +@ApplicationScoped +public class MembershipRepo implements PanacheRepositoryBase +{ + public List findByTenantTypeAndAccount(TenantType tenantType, AccountEntity account) + { + return list("tenantType = :tenantType AND account = :account", Parameters.with("tenantType", tenantType).and("account", account)); + } + + public List findByTenantTypeAndTenantId(TenantType tenantType, String tenantId) + { + return list("tenantType = :tenantType AND tenantId = :tenantId", Parameters.with("tenantType", tenantType).and("tenantId", tenantId)); + } +} diff --git a/src/main/java/de/tavolio/member/MembershipRole.java b/src/main/java/de/tavolio/member/MembershipRole.java new file mode 100644 index 0000000..c872438 --- /dev/null +++ b/src/main/java/de/tavolio/member/MembershipRole.java @@ -0,0 +1,6 @@ +package de.tavolio.member; + +public enum MembershipRole +{ + OWNER, ADMIN, MEMBER +} diff --git a/src/main/java/de/tavolio/member/MembershipService.java b/src/main/java/de/tavolio/member/MembershipService.java new file mode 100644 index 0000000..58fe509 --- /dev/null +++ b/src/main/java/de/tavolio/member/MembershipService.java @@ -0,0 +1,87 @@ +package de.tavolio.member; + +import de.tavolio.AuthenticationService; +import de.tavolio.account.AccountEntity; +import de.tavolio.account.AccountRepo; +import de.tavolio.account.AccountStatus; +import de.tavolio.member.dto.Membership; +import de.tavolio.member.dto.MembershipCreation; +import io.quarkus.security.UnauthorizedException; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.transaction.Transactional; +import jakarta.ws.rs.BadRequestException; +import org.jboss.logging.Logger; + +import java.time.ZonedDateTime; +import java.util.List; + +@ApplicationScoped +public class MembershipService +{ + @Inject + Logger LOG; + + @Inject + AuthenticationService authenticationService; + + @Inject + MembershipRepo membershipRepo; + + @Inject + MembershipMapper membershipMapper; + @Inject + AccountRepo accountRepo; + + @Transactional + public Membership create(TenantType tenantType, String tenantId, MembershipCreation membershipCreation) + { + switch (tenantType) + { + case ORGANISATION -> + { + if (membershipCreation.role().equals(MembershipRole.OWNER)) + { + if (authenticationService.isSuperUser()) + { + AccountEntity account = accountRepo.findById(membershipCreation.accountId()); + + MembershipEntity membership = MembershipEntity.init(); + membership.setAccount(account); + membership.setRole(membershipCreation.role()); + membership.setTenantType(TenantType.ORGANISATION); + membership.setTenantId(tenantId); + membership.setMemberSince(ZonedDateTime.now()); + membershipRepo.persist(membership); + + account.setStatus(AccountStatus.REGISTERED); + accountRepo.persist(account); + + return membershipMapper.map(membership); + } + LOG.errorf("Membership with role 'Owner' cannot be created without superuser permissions"); + throw new UnauthorizedException(); + } + } + case RESTAURANT -> + { + + } + default -> + { + + } + } + throw new BadRequestException(); + } + + public List findByTenantType(TenantType tenantType) + { + return membershipMapper.map(membershipRepo.findByTenantTypeAndAccount(tenantType, authenticationService.requireUser())); + } + + public List findByTenantTypeAndTenantId(TenantType tenantType, String tenantId) + { + return membershipMapper.map(membershipRepo.findByTenantTypeAndTenantId(tenantType, tenantId)); + } +} diff --git a/src/main/java/de/tavolio/member/TenantMembershipResource.java b/src/main/java/de/tavolio/member/TenantMembershipResource.java new file mode 100644 index 0000000..3d887e9 --- /dev/null +++ b/src/main/java/de/tavolio/member/TenantMembershipResource.java @@ -0,0 +1,80 @@ +package de.tavolio.member; + +import de.tavolio.member.dto.Membership; +import de.tavolio.member.dto.MembershipCreation; +import jakarta.inject.Inject; +import jakarta.ws.rs.*; +import org.jboss.logging.Logger; +import org.jboss.resteasy.reactive.common.NotImplementedYet; + +import java.util.List; + +@Path("/{tenant-type}") +public class TenantMembershipResource +{ + @Inject + Logger LOG; + + @Inject + MembershipService membershipService; + + @POST + @Path("/{tenant-id}/memberships") + public Membership post(@PathParam("tenant-type") String tenantType, @PathParam("tenant-id") String tenantId, MembershipCreation membershipCreation) + { + switch (tenantType) + { + case "organisations" -> + { + Membership membership = membershipService.create(TenantType.ORGANISATION, tenantId, membershipCreation); + LOG.infof("Created membership for organisation %s", tenantId); + return membership; + } + case "restaurants" -> + { + Membership membership = membershipService.create(TenantType.RESTAURANT, tenantId, membershipCreation); + LOG.infof("Created membership for restaurant %s", tenantId); + return membership; + } + } + throw new BadRequestException(); + } + + @GET + @Path("/{tenant-id}/memberships") + public List get(@PathParam("tenant-type") String tenantType, @PathParam("tenant-id") String tenantId) + { + switch (tenantType) + { + case "organisations" -> + { + return membershipService.findByTenantTypeAndTenantId(TenantType.ORGANISATION, tenantId); + } + case "restaurants" -> + { + throw new NotImplementedYet(); + } + } + LOG.errorf("Unknown tenant type %s", tenantType); + throw new BadRequestException(); + } + + @GET + @Path("/memberships") + public List get(@PathParam("tenant-type") String tenantType) + { + switch (tenantType) + { + case "organisations" -> + { + return membershipService.findByTenantType(TenantType.ORGANISATION); + } + case "restaurants" -> + { + return membershipService.findByTenantType(TenantType.RESTAURANT); + } + } + LOG.errorf("Unknown tenant type %s", tenantType); + throw new BadRequestException(); + } +} diff --git a/src/main/java/de/tavolio/member/TenantType.java b/src/main/java/de/tavolio/member/TenantType.java new file mode 100644 index 0000000..6f226b9 --- /dev/null +++ b/src/main/java/de/tavolio/member/TenantType.java @@ -0,0 +1,6 @@ +package de.tavolio.member; + +public enum TenantType +{ + ORGANISATION, RESTAURANT +} diff --git a/src/main/java/de/tavolio/member/dto/AccountMemberships.java b/src/main/java/de/tavolio/member/dto/AccountMemberships.java new file mode 100644 index 0000000..00d1308 --- /dev/null +++ b/src/main/java/de/tavolio/member/dto/AccountMemberships.java @@ -0,0 +1,7 @@ +package de.tavolio.member.dto; + +import java.util.List; + +public record AccountMemberships(List organisations, List restaurants) +{ +} diff --git a/src/main/java/de/tavolio/member/dto/Membership.java b/src/main/java/de/tavolio/member/dto/Membership.java new file mode 100644 index 0000000..e064db7 --- /dev/null +++ b/src/main/java/de/tavolio/member/dto/Membership.java @@ -0,0 +1,9 @@ +package de.tavolio.member.dto; + +import de.tavolio.account.dto.Account; +import de.tavolio.member.MembershipRole; +import de.tavolio.member.TenantType; + +public record Membership(String id, TenantType tenantType, String tenantId, MembershipRole role, Account account) +{ +} diff --git a/src/main/java/de/tavolio/member/dto/MembershipCreation.java b/src/main/java/de/tavolio/member/dto/MembershipCreation.java new file mode 100644 index 0000000..a321d8e --- /dev/null +++ b/src/main/java/de/tavolio/member/dto/MembershipCreation.java @@ -0,0 +1,7 @@ +package de.tavolio.member.dto; + +import de.tavolio.member.MembershipRole; + +public record MembershipCreation(String accountId, MembershipRole role) +{ +} diff --git a/src/main/java/de/tavolio/organisation/member/OrganisationMemberResource.java b/src/main/java/de/tavolio/organisation/member/OrganisationMemberResource.java deleted file mode 100644 index 3a7f008..0000000 --- a/src/main/java/de/tavolio/organisation/member/OrganisationMemberResource.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.tavolio.organisation.member; - -public class OrganisationMemberResource -{ -} diff --git a/src/main/java/de/tavolio/organisation/member/OrganisationMembershipRepo.java b/src/main/java/de/tavolio/organisation/member/OrganisationMembershipRepo.java deleted file mode 100644 index c3d22e8..0000000 --- a/src/main/java/de/tavolio/organisation/member/OrganisationMembershipRepo.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.tavolio.organisation.member; - -public class OrganisationMembershipRepo -{ -} diff --git a/src/main/java/de/tavolio/restaurant/member/OrganisationMemberResource.java b/src/main/java/de/tavolio/restaurant/member/OrganisationMemberResource.java deleted file mode 100644 index 183ccf5..0000000 --- a/src/main/java/de/tavolio/restaurant/member/OrganisationMemberResource.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.tavolio.restaurant.member; - -public class OrganisationMemberResource -{ -} diff --git a/src/main/java/de/tavolio/restaurant/member/RestaurantMembershipRepo.java b/src/main/java/de/tavolio/restaurant/member/RestaurantMembershipRepo.java deleted file mode 100644 index e45c289..0000000 --- a/src/main/java/de/tavolio/restaurant/member/RestaurantMembershipRepo.java +++ /dev/null @@ -1,5 +0,0 @@ -package de.tavolio.restaurant.member; - -public class RestaurantMembershipRepo -{ -} diff --git a/src/main/java/de/tavolio/session/SessionResource.java b/src/main/java/de/tavolio/session/SessionResource.java index 2c1c2e1..df4f768 100644 --- a/src/main/java/de/tavolio/session/SessionResource.java +++ b/src/main/java/de/tavolio/session/SessionResource.java @@ -1,15 +1,26 @@ package de.tavolio.session; +import de.tavolio.session.dto.SessionCreation; +import jakarta.inject.Inject; +import jakarta.validation.Valid; import jakarta.ws.rs.POST; import jakarta.ws.rs.Path; -import org.jboss.resteasy.reactive.common.NotImplementedYet; +import org.jboss.logging.Logger; @Path("/sessions") public class SessionResource { + @Inject + Logger LOG; + + @Inject + SessionService sessionService; + @POST - public String get() + public String get(@Valid SessionCreation sessionCreation) { - throw new NotImplementedYet(); + String token = sessionService.generateBySessionCreation(sessionCreation); + LOG.infof("Generated token for email %s", sessionCreation.email()); + return token; } } diff --git a/src/main/java/de/tavolio/session/SessionService.java b/src/main/java/de/tavolio/session/SessionService.java new file mode 100644 index 0000000..24500be --- /dev/null +++ b/src/main/java/de/tavolio/session/SessionService.java @@ -0,0 +1,39 @@ +package de.tavolio.session; + +import de.tavolio.account.AccountEntity; +import de.tavolio.account.AccountRepo; +import de.tavolio.session.dto.SessionCreation; +import io.quarkus.elytron.security.common.BcryptUtil; +import io.smallrye.jwt.build.Jwt; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; +import jakarta.ws.rs.NotFoundException; + +import java.time.ZonedDateTime; +import java.util.Optional; + +@ApplicationScoped +public class SessionService +{ + @Inject + AccountRepo accountRepo; + + public String generateBySessionCreation(SessionCreation sessionCreation) + { + Optional accountEntityOptional = accountRepo.findOptionalByEmail(sessionCreation.email()); + if (accountEntityOptional.isPresent()) + { + AccountEntity accountEntity = accountEntityOptional.get(); + if (BcryptUtil.matches(sessionCreation.password(), accountEntity.getPassword())) + { + return generateToken(accountEntity.getId()); + } + } + throw new NotFoundException(); + } + + private String generateToken(String upn) + { + return Jwt.upn(upn).expiresAt(ZonedDateTime.now().plusYears(1).toInstant()).issuer("https://tavolio.de").sign(); + } +} diff --git a/src/main/java/de/tavolio/session/dto/SessionCreation.java b/src/main/java/de/tavolio/session/dto/SessionCreation.java new file mode 100644 index 0000000..cafc6b4 --- /dev/null +++ b/src/main/java/de/tavolio/session/dto/SessionCreation.java @@ -0,0 +1,10 @@ +package de.tavolio.session.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record SessionCreation( + @Email String email, + @NotBlank String password) +{ +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index cb0c135..565742e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -5,6 +5,14 @@ quarkus.http.test-port=9089 quarkus.hibernate-orm.schema-management.strategy=drop-and-create +smallrye.jwt.sign.key.location=private.key +mp.jwt.verify.publickey.location=public.crt +mp.jwt.verify.issuer=https://tavolio.de + %dev.quarkus.datasource.username=postgres %dev.quarkus.datasource.password=${DB_PASSWORD} -%dev.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres?currentSchema=iam \ No newline at end of file +%dev.quarkus.datasource.jdbc.url=jdbc:postgresql://localhost:5432/postgres?currentSchema=iam + +# IAM Superuser +%dev.iam.user.name=tavolio +%dev.iam.user.password=tavolio \ No newline at end of file diff --git a/src/main/resources/import.sql b/src/main/resources/import.sql index 16aa523..6b68a2d 100644 --- a/src/main/resources/import.sql +++ b/src/main/resources/import.sql @@ -3,4 +3,10 @@ -- insert into myentity (id, field) values(1, 'field-1'); -- insert into myentity (id, field) values(2, 'field-2'); -- insert into myentity (id, field) values(3, 'field-3'); --- alter sequence myentity_seq restart with 4; \ No newline at end of file +-- alter sequence myentity_seq restart with 4; + +INSERT INTO account (id, firstname, lastname, email, password, status) +VALUES ('66b261fe-4c5a-4728-9857-67717f02d4e1', 'Andreas', 'Dinauer', 'andreas.j.dinauer@gmail.com', '$2a$12$cdrzIY4sMFAXiz29uo9Ul.MPy0RN0FGS2yjVzb5BTe6bSijn4eGQy', 'REGISTERED'); + +INSERT INTO membership(id, tenant_type, tenant_id, member_role, member_since, account_id) +VALUES ('cd20d271-6e76-48c4-9cfb-a67f8892d46c', 'ORGANISATION', 'b3912be6-7503-4a13-b8ab-5d65af036742', 'OWNER', '2025-08-20 15:30:12.123456+02', '66b261fe-4c5a-4728-9857-67717f02d4e1'); \ No newline at end of file diff --git a/src/test/java/de/tavolio/account/AccountResourceTest.java b/src/test/java/de/tavolio/account/AccountResourceTest.java index e38826c..dc2b619 100644 --- a/src/test/java/de/tavolio/account/AccountResourceTest.java +++ b/src/test/java/de/tavolio/account/AccountResourceTest.java @@ -1,23 +1,34 @@ package de.tavolio.account; -import io.quarkus.deployment.dev.testing.TestConfig; +import de.tavolio.utils.Database; +import io.quarkus.elytron.security.common.BcryptUtil; import io.quarkus.test.junit.QuarkusTest; -import io.restassured.RestAssured; +import io.quarkus.test.security.TestSecurity; import io.restassured.http.ContentType; +import io.restassured.response.Response; import jakarta.inject.Inject; -import org.junit.jupiter.api.Assertions; -import org.junit.jupiter.api.Order; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static io.restassured.RestAssured.when; +import static org.junit.jupiter.api.Assertions.*; @QuarkusTest public class AccountResourceTest { + @Inject + Database database; + @Inject AccountRepo accountRepo; + @AfterEach + void afterEach() + { + database.clear(); + } + @Test void testInsert() { @@ -40,4 +51,35 @@ public class AccountResourceTest assertEquals(1, accountRepo.count()); } + + @Test + @TestSecurity(user = "66609092-6c98-4466-af52-9a3e9d633108") + void testRetrieval() + { + // given + database.setup(() -> { + AccountEntity account = new AccountEntity() + .setId("66609092-6c98-4466-af52-9a3e9d633108") + .setFirstname("Andreas") + .setLastname("Dinauer") + .setEmail("andreas.j.dinauer@gmail.com") + .setPassword(BcryptUtil.bcryptHash("pw")); + accountRepo.persist(account); + }); + + // when + Response response = when() + .get("accounts") + .then() + .extract() + .response(); + + // then + String body = response.getBody().prettyPrint(); + assertFalse(body.isEmpty()); + assertTrue(body.contains("66609092-6c98-4466-af52-9a3e9d633108")); + assertTrue(body.contains("Andreas")); + assertTrue(body.contains("Dinauer")); + assertTrue(body.contains("andreas.j.dinauer@gmail.com")); + } } diff --git a/src/test/java/de/tavolio/session/SessionResourceTest.java b/src/test/java/de/tavolio/session/SessionResourceTest.java new file mode 100644 index 0000000..63c4d2e --- /dev/null +++ b/src/test/java/de/tavolio/session/SessionResourceTest.java @@ -0,0 +1,67 @@ +package de.tavolio.session; + +import de.tavolio.account.AccountEntity; +import de.tavolio.account.AccountRepo; +import de.tavolio.utils.Database; +import io.quarkus.elytron.security.common.BcryptUtil; +import io.quarkus.test.junit.QuarkusTest; +import io.restassured.http.ContentType; +import io.restassured.response.Response; +import jakarta.inject.Inject; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; + +import static io.restassured.RestAssured.given; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@QuarkusTest +public class SessionResourceTest +{ + @Inject + Database database; + + @Inject + AccountRepo accountRepo; + + @AfterEach + void afterEach() + { + database.clear(); + } + + @Test + void testGet() + { + // given + database.setup(() -> { + AccountEntity accountEntity = AccountEntity.init() + .setEmail("andreas.j.dinauer@gmail.com") + .setFirstname("Andreas") + .setLastname("Dinauer") + .setPassword(BcryptUtil.bcryptHash("pw")); + accountRepo.persist(accountEntity); + }); + + String loginRequest = """ + { + "email": "andreas.j.dinauer@gmail.com", + "password": "pw" + } + """; + + // when + Response response = given() + .contentType(ContentType.JSON) + .body(loginRequest) + .when() + .post("/sessions") + .then() + .extract() + .response(); + + // then + assertEquals(200, response.statusCode()); + assertTrue(response.getBody().prettyPrint().startsWith("ey")); + } +} diff --git a/src/test/java/de/tavolio/utils/Database.java b/src/test/java/de/tavolio/utils/Database.java new file mode 100644 index 0000000..1a21467 --- /dev/null +++ b/src/test/java/de/tavolio/utils/Database.java @@ -0,0 +1,67 @@ +package de.tavolio.utils; + +import io.agroal.api.AgroalDataSource; +import io.quarkus.narayana.jta.QuarkusTransaction; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.LinkedList; +import java.util.List; + +@ApplicationScoped +public class Database { + + @Inject + AgroalDataSource dataSource; + + public void setup(Runnable setup) { + QuarkusTransaction.begin(); + setup.run(); + QuarkusTransaction.commit(); + } + + public void clear() { + forceForeignKeys(false); + getAllTables().forEach(this::truncateTable); + forceForeignKeys(true); + } + + private List getAllTables() { + try(Connection connection = dataSource.getConnection()) { + ResultSet rs = connection.getMetaData().getTables(null, "public", "%", new String[] {"TABLE"}); + connection.close(); + List tables = new LinkedList<>(); + while(rs.next()) { + tables.add(rs.getString(3)); + } + return tables; + } catch (SQLException e) { + throw new RuntimeException(); + } + } + + private void forceForeignKeys(boolean force) { + if(force) { + execute("SET session_replication_role = replica"); + } else { + execute("SET session_replication_role = DEFAULT"); + } + } + + private void truncateTable(String table) { + execute("TRUNCATE TABLE public." + table + " RESTART IDENTITY CASCADE"); + } + + private void execute(String sql) { + try(Connection connection = dataSource.getConnection(); Statement statement = connection.createStatement()) { + statement.execute(sql); + } catch (SQLException e) { + System.out.println(sql + ": Error executing SQL."); + } + } + +} \ No newline at end of file