From 63c953d9b9523a772b1adce2cfac0eafdfb64698 Mon Sep 17 00:00:00 2001 From: Patrick Plate Date: Wed, 24 Jun 2026 15:46:54 +0200 Subject: [PATCH] feat(w2): auth core entities + Google OAuth + JWT + NextAuth bridge Extracted from InspectFlow Sprint 14.1-14.2, repackaged to de.platesoft.auth.*: - Entities: User, UserIdentity, Membership, Invitation, AccessRequest, LoginEvent, RefreshToken - Enums: Role, OrgType, MembershipRole, MembershipStatus, InvitationStatus, AccessRequestStatus, LoginProvider - Services: JwtService, ExchangeService, MembershipService, LoginEventService - Filter: JwtAuthenticationFilter - Controller: OAuthController (POST /api/auth/exchange) - Config: PlateAuthAutoConfiguration, PlateAuthProperties (plate.auth.* namespace) - Repositories: all auth-related JPA repositories - SPI: OrgValidator, OrgDisplayNameResolver, InvitationMailer, AccessRequestMailer, OnboardingHook - SPI defaults: PermissiveOrgValidator (WARN per call), LoggingInvitationMailer, LoggingAccessRequestMailer, DefaultOrgDisplayNameResolver, NoOpOnboardingHook - DTOs: ExchangePayload, TokenResponse - Security: BCrypt encoder, stateless session, CORS from PlateAuthProperties - META-INF/spring AutoConfiguration.imports registered All @Value refs replaced with PlateAuthProperties injection. No references to de.platesoft.inspectflow.* remain. --- plate-auth-starter/pom.xml | 4 + .../auth/PlateAuthAutoConfiguration.java | 117 +++++++++++++ .../platesoft/auth/PlateAuthProperties.java | 73 ++++++++ .../auth/controller/OAuthController.java | 27 +++ .../platesoft/auth/dto/ExchangePayload.java | 17 ++ .../de/platesoft/auth/dto/TokenResponse.java | 7 + .../platesoft/auth/entity/AccessRequest.java | 64 +++++++ .../auth/entity/AccessRequestStatus.java | 3 + .../de/platesoft/auth/entity/Invitation.java | 74 ++++++++ .../auth/entity/InvitationStatus.java | 3 + .../de/platesoft/auth/entity/LoginEvent.java | 46 +++++ .../platesoft/auth/entity/LoginProvider.java | 3 + .../de/platesoft/auth/entity/Membership.java | 64 +++++++ .../platesoft/auth/entity/MembershipRole.java | 9 + .../auth/entity/MembershipStatus.java | 3 + .../de/platesoft/auth/entity/OrgType.java | 3 + .../platesoft/auth/entity/RefreshToken.java | 42 +++++ .../java/de/platesoft/auth/entity/Role.java | 9 + .../java/de/platesoft/auth/entity/User.java | 78 +++++++++ .../platesoft/auth/entity/UserIdentity.java | 55 ++++++ .../auth/filter/JwtAuthenticationFilter.java | 47 +++++ .../repository/AccessRequestRepository.java | 15 ++ .../auth/repository/InvitationRepository.java | 16 ++ .../auth/repository/LoginEventRepository.java | 7 + .../auth/repository/MembershipRepository.java | 16 ++ .../repository/RefreshTokenRepository.java | 12 ++ .../repository/UserIdentityRepository.java | 11 ++ .../auth/repository/UserRepository.java | 12 ++ .../auth/service/ExchangeService.java | 162 ++++++++++++++++++ .../de/platesoft/auth/service/JwtService.java | 96 +++++++++++ .../auth/service/LoginEventService.java | 42 +++++ .../auth/service/MembershipService.java | 116 +++++++++++++ .../auth/spi/AccessRequestMailer.java | 12 ++ .../platesoft/auth/spi/InvitationMailer.java | 10 ++ .../de/platesoft/auth/spi/OnboardingHook.java | 16 ++ .../auth/spi/OrgDisplayNameResolver.java | 13 ++ .../de/platesoft/auth/spi/OrgValidator.java | 19 ++ .../DefaultOrgDisplayNameResolver.java | 17 ++ .../defaults/LoggingAccessRequestMailer.java | 25 +++ .../spi/defaults/LoggingInvitationMailer.java | 19 ++ .../auth/spi/defaults/NoOpOnboardingHook.java | 16 ++ .../spi/defaults/PermissiveOrgValidator.java | 22 +++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + 43 files changed, 1423 insertions(+) create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthProperties.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/controller/OAuthController.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/dto/ExchangePayload.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/dto/TokenResponse.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequest.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequestStatus.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/Invitation.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/InvitationStatus.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginEvent.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginProvider.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/Membership.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipRole.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipStatus.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/OrgType.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/RefreshToken.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/Role.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/User.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/entity/UserIdentity.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/filter/JwtAuthenticationFilter.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/AccessRequestRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/InvitationRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/LoginEventRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/MembershipRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/RefreshTokenRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserIdentityRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserRepository.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/service/JwtService.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/service/LoginEventService.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/service/MembershipService.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/AccessRequestMailer.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/InvitationMailer.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/OnboardingHook.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgDisplayNameResolver.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgValidator.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/DefaultOrgDisplayNameResolver.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingAccessRequestMailer.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingInvitationMailer.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/NoOpOnboardingHook.java create mode 100644 plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/PermissiveOrgValidator.java create mode 100644 plate-auth-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports diff --git a/plate-auth-starter/pom.xml b/plate-auth-starter/pom.xml index 45bcca0..dc7e650 100644 --- a/plate-auth-starter/pom.xml +++ b/plate-auth-starter/pom.xml @@ -41,6 +41,10 @@ org.springframework.boot spring-boot-starter-mail + + com.fasterxml.jackson.core + jackson-databind + diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java new file mode 100644 index 0000000..d623594 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java @@ -0,0 +1,117 @@ +package de.platesoft.auth; + +import de.platesoft.auth.filter.JwtAuthenticationFilter; +import de.platesoft.auth.service.JwtService; +import de.platesoft.auth.spi.*; +import de.platesoft.auth.spi.defaults.*; +import org.springframework.boot.autoconfigure.AutoConfiguration; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.ComponentScan; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +@AutoConfiguration +@EnableConfigurationProperties(PlateAuthProperties.class) +@ComponentScan(basePackages = "de.platesoft.auth") +@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository") +@EnableAsync +@ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true) +public class PlateAuthAutoConfiguration { + + // ── SPI defaults ───────────────────────────────────────────────────────── + + @Bean + @ConditionalOnMissingBean(OrgValidator.class) + public OrgValidator orgValidator() { + return new PermissiveOrgValidator(); + } + + @Bean + @ConditionalOnMissingBean(OrgDisplayNameResolver.class) + public OrgDisplayNameResolver orgDisplayNameResolver() { + return new DefaultOrgDisplayNameResolver(); + } + + @Bean + @ConditionalOnMissingBean(InvitationMailer.class) + public InvitationMailer invitationMailer() { + return new LoggingInvitationMailer(); + } + + @Bean + @ConditionalOnMissingBean(AccessRequestMailer.class) + public AccessRequestMailer accessRequestMailer() { + return new LoggingAccessRequestMailer(); + } + + @Bean + @ConditionalOnMissingBean(OnboardingHook.class) + public OnboardingHook onboardingHook() { + return new NoOpOnboardingHook(); + } + + // ── Security ───────────────────────────────────────────────────────────── + + @Bean + @ConditionalOnMissingBean(PasswordEncoder.class) + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) { + return new JwtAuthenticationFilter(jwtService); + } + + @Bean + public SecurityFilterChain plateAuthSecurityFilterChain( + HttpSecurity http, + JwtAuthenticationFilter jwtFilter, + PlateAuthProperties props) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .cors(cors -> cors.configurationSource(corsConfigurationSource(props))) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/api/auth/exchange", + "/api/auth/login", + "/api/auth/register", + "/api/auth/refresh", + "/api/auth/config", + "/actuator/health" + ).permitAll() + .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN") + .anyRequest().authenticated() + ) + .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); + return http.build(); + } + + private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) { + CorsConfiguration config = new CorsConfiguration(); + config.setAllowedOrigins(props.getCors().getAllowedOrigins()); + config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + config.setAllowedHeaders(List.of("*")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthProperties.java b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthProperties.java new file mode 100644 index 0000000..b4e92a3 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthProperties.java @@ -0,0 +1,73 @@ +package de.platesoft.auth; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.validation.annotation.Validated; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = "plate.auth") +@Validated +@Data +public class PlateAuthProperties { + + private Jwt jwt = new Jwt(); + private Exchange exchange = new Exchange(); + private Registration registration = new Registration(); + private Cors cors = new Cors(); + private Providers providers = new Providers(); + + @Data + public static class Jwt { + @NotBlank + @Size(min = 32, message = "JWT secret must be at least 32 characters") + private String secret; + private Duration accessExpiration = Duration.ofMinutes(15); + private Duration refreshExpiration = Duration.ofDays(30); + private String issuer = "plate-auth"; + } + + @Data + public static class Exchange { + @NotBlank + @Size(min = 32, message = "Exchange secret must be at least 32 characters") + private String secret; + private Duration maxAge = Duration.ofSeconds(60); + private Duration nonceTtl = Duration.ofMinutes(5); + } + + @Data + public static class Registration { + private boolean enabled = false; + } + + @Data + public static class Cors { + private List allowedOrigins = new ArrayList<>(); + private List additionalPermitPaths = new ArrayList<>(); + } + + @Data + public static class Providers { + private ProviderToggle google = new ProviderToggle(true); + private ProviderToggle microsoft = new ProviderToggle(false); + private ProviderToggle emailMagicLink = new ProviderToggle(false); + } + + @Data + public static class ProviderToggle { + private boolean enabled; + + public ProviderToggle() { + this.enabled = false; + } + + public ProviderToggle(boolean enabled) { + this.enabled = enabled; + } + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/controller/OAuthController.java b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/OAuthController.java new file mode 100644 index 0000000..dd83b98 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/OAuthController.java @@ -0,0 +1,27 @@ +package de.platesoft.auth.controller; + +import de.platesoft.auth.dto.TokenResponse; +import de.platesoft.auth.service.ExchangeService; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/auth/exchange") +@RequiredArgsConstructor +public class OAuthController { + + private final ExchangeService exchangeService; + + @PostMapping + public ResponseEntity exchange( + @RequestBody String body, + @RequestHeader(value = "X-Exchange-Signature", required = false) String signature, + HttpServletRequest request) { + String ip = request.getRemoteAddr(); + String ua = request.getHeader("User-Agent"); + TokenResponse tokens = exchangeService.verifyAndExchange(body, signature, ip, ua); + return ResponseEntity.ok(tokens); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/ExchangePayload.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/ExchangePayload.java new file mode 100644 index 0000000..20fc7a4 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/ExchangePayload.java @@ -0,0 +1,17 @@ +package de.platesoft.auth.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +/** + * Payload of the HMAC-signed exchange envelope from NextAuth signIn callback. + */ +@JsonIgnoreProperties(ignoreUnknown = true) +public record ExchangePayload( + String provider, + String providerSubject, + String email, + String name, + String inviteToken, + String nonce, + long iat +) {} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/TokenResponse.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/TokenResponse.java new file mode 100644 index 0000000..7bad231 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/TokenResponse.java @@ -0,0 +1,7 @@ +package de.platesoft.auth.dto; + +public record TokenResponse( + String accessToken, + String refreshToken, + long expiresIn +) {} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequest.java new file mode 100644 index 0000000..2a6430b --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequest.java @@ -0,0 +1,64 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.envers.Audited; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "access_requests") +@Audited +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class AccessRequest { + + @Id + @Column(columnDefinition = "uuid") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "requester_id", nullable = false) + private User requester; + + @Enumerated(EnumType.STRING) + @Column(name = "org_type", nullable = false, length = 16) + private OrgType orgType; + + @Column(name = "org_id", nullable = false, columnDefinition = "uuid") + private UUID orgId; + + @Enumerated(EnumType.STRING) + @Column(name = "requested_role", nullable = false, length = 16) + private MembershipRole requestedRole; + + @Column(length = 500) + private String justification; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private AccessRequestStatus status; + + @Column(name = "reviewer_id", columnDefinition = "uuid") + private UUID reviewerId; + + @Column(name = "decision_reason", length = 500) + private String decisionReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "decided_at") + private Instant decidedAt; + + @Version + private Long version; + + @PrePersist + void prePersist() { + if (id == null) id = UUID.randomUUID(); + if (createdAt == null) createdAt = Instant.now(); + if (status == null) status = AccessRequestStatus.PENDING; + if (requestedRole == null) requestedRole = MembershipRole.VIEWER; + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequestStatus.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequestStatus.java new file mode 100644 index 0000000..4ba42b5 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/AccessRequestStatus.java @@ -0,0 +1,3 @@ +package de.platesoft.auth.entity; + +public enum AccessRequestStatus { PENDING, APPROVED, DENIED, EXPIRED } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Invitation.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Invitation.java new file mode 100644 index 0000000..2492ecd --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Invitation.java @@ -0,0 +1,74 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.envers.Audited; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "invitations") +@Audited +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class Invitation { + + @Id + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(nullable = false, unique = true, length = 64) + private String token; + + @Column(nullable = false, length = 255) + private String email; + + @Enumerated(EnumType.STRING) + @Column(name = "org_type", nullable = false, length = 16) + private OrgType orgType; + + @Column(name = "org_id", nullable = false, columnDefinition = "uuid") + private UUID orgId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private MembershipRole role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private InvitationStatus status; + + @Column(name = "created_by", nullable = false, columnDefinition = "uuid") + private UUID createdBy; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(name = "accepted_at") + private Instant acceptedAt; + + @Column(name = "accepted_by", columnDefinition = "uuid") + private UUID acceptedBy; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "revoked_by", columnDefinition = "uuid") + private UUID revokedBy; + + @Column(length = 500) + private String note; + + @Version + private Long version; + + @PrePersist + void prePersist() { + if (id == null) id = UUID.randomUUID(); + if (createdAt == null) createdAt = Instant.now(); + if (status == null) status = InvitationStatus.PENDING; + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/InvitationStatus.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/InvitationStatus.java new file mode 100644 index 0000000..f3b868a --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/InvitationStatus.java @@ -0,0 +1,3 @@ +package de.platesoft.auth.entity; + +public enum InvitationStatus { PENDING, ACCEPTED, REVOKED, EXPIRED } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginEvent.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginEvent.java new file mode 100644 index 0000000..9503053 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginEvent.java @@ -0,0 +1,46 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "login_events") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class LoginEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "user_id", columnDefinition = "uuid") + private UUID userId; + + @Column(nullable = false, length = 255) + private String email; + + @Column(nullable = false, length = 32) + private String provider; + + @Column(nullable = false, length = 32) + private String outcome; + + @Column(name = "ip_address", length = 45) + private String ipAddress; + + @Column(name = "user_agent", length = 512) + private String userAgent; + + @Column(name = "correlation_id", length = 64) + private String correlationId; + + @Column(name = "occurred_at", nullable = false) + private Instant occurredAt; + + @PrePersist + void prePersist() { + if (occurredAt == null) occurredAt = Instant.now(); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginProvider.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginProvider.java new file mode 100644 index 0000000..f3c61db --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/LoginProvider.java @@ -0,0 +1,3 @@ +package de.platesoft.auth.entity; + +public enum LoginProvider { GOOGLE, MICROSOFT, EMAIL, PASSWORD } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Membership.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Membership.java new file mode 100644 index 0000000..0b23591 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Membership.java @@ -0,0 +1,64 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.envers.Audited; + +import java.time.Instant; +import java.util.UUID; + +@Entity +@Table(name = "memberships", uniqueConstraints = { + @UniqueConstraint(name = "uq_memberships_user_org", columnNames = {"user_id", "org_type", "org_id"}) +}) +@Audited +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class Membership { + + @Id @Column(columnDefinition = "uuid") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Enumerated(EnumType.STRING) + @Column(name = "org_type", nullable = false, length = 16) + private OrgType orgType; + + @Column(name = "org_id", nullable = false, columnDefinition = "uuid") + private UUID orgId; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private MembershipRole role; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 16) + private MembershipStatus status; + + @Column(name = "granted_by", columnDefinition = "uuid") + private UUID grantedBy; + + @Column(name = "grant_reason", length = 64) + private String grantReason; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @Column(name = "revoked_at") + private Instant revokedAt; + + @Column(name = "revoked_by", columnDefinition = "uuid") + private UUID revokedBy; + + @Version + private Long version; + + @PrePersist + void prePersist() { + if (id == null) id = UUID.randomUUID(); + if (createdAt == null) createdAt = Instant.now(); + if (status == null) status = MembershipStatus.ACTIVE; + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipRole.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipRole.java new file mode 100644 index 0000000..0e6d20f --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipRole.java @@ -0,0 +1,9 @@ +package de.platesoft.auth.entity; + +public enum MembershipRole { + OWNER, + ADMIN, + INSPECTOR, + VIEWER, + MEMBER +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipStatus.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipStatus.java new file mode 100644 index 0000000..9b71c75 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/MembershipStatus.java @@ -0,0 +1,3 @@ +package de.platesoft.auth.entity; + +public enum MembershipStatus { ACTIVE, REVOKED, PENDING } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/OrgType.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/OrgType.java new file mode 100644 index 0000000..3d4f037 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/OrgType.java @@ -0,0 +1,3 @@ +package de.platesoft.auth.entity; + +public enum OrgType { COMPANY, CLUB, WORKSPACE } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/RefreshToken.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/RefreshToken.java new file mode 100644 index 0000000..f908fac --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/RefreshToken.java @@ -0,0 +1,42 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.envers.Audited; + +import java.time.Instant; +import java.util.UUID; + +/** + * Refresh token entity for rotation tracking. + */ +@Entity +@Table(name = "refresh_tokens") +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class RefreshToken { + + @Id + @Column(columnDefinition = "uuid") + private UUID id; + + @Column(name = "user_id", nullable = false, columnDefinition = "uuid") + private UUID userId; + + @Column(nullable = false, unique = true, length = 255) + private String token; + + @Column(name = "expires_at", nullable = false) + private Instant expiresAt; + + @Column(nullable = false) + private boolean revoked; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + @PrePersist + void prePersist() { + if (id == null) id = UUID.randomUUID(); + if (createdAt == null) createdAt = Instant.now(); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Role.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Role.java new file mode 100644 index 0000000..a4cb913 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/Role.java @@ -0,0 +1,9 @@ +package de.platesoft.auth.entity; + +/** + * Global user roles. Consumers may extend via MembershipRole for per-org roles. + */ +public enum Role { + ROLE_USER, + ROLE_ADMIN +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/User.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/User.java new file mode 100644 index 0000000..27c74ec --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/User.java @@ -0,0 +1,78 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.envers.Audited; +import org.hibernate.envers.NotAudited; + +import java.time.OffsetDateTime; +import java.util.UUID; + +@Entity +@Table(name = "users") +@Audited +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class User { + + @Id + @Column(nullable = false, updatable = false) + private UUID id; + + @Column(nullable = false, unique = true) + private String email; + + @NotAudited + @Column(name = "password_hash") + private String passwordHash; + + @Column(name = "first_name", nullable = false, length = 100) + private String firstName; + + @Column(name = "last_name", nullable = false, length = 100) + private String lastName; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 50) + private Role role; + + @Column(nullable = false) + private boolean active; + + @Column(name = "default_org_id") + private UUID defaultOrgId; + + @Column(name = "last_provider", length = 32) + private String lastProvider; + + @Version + @NotAudited + private Long version; + + @Column(name = "last_login") + private OffsetDateTime lastLogin; + + @NotAudited + @Column(name = "created_at", nullable = false, updatable = false) + private OffsetDateTime createdAt; + + @NotAudited + @Column(name = "updated_at", nullable = false) + private OffsetDateTime updatedAt; + + @PrePersist + protected void onCreate() { + if (id == null) id = UUID.randomUUID(); + createdAt = OffsetDateTime.now(); + updatedAt = OffsetDateTime.now(); + if (!active) active = true; + } + + @PreUpdate + protected void onUpdate() { + updatedAt = OffsetDateTime.now(); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/entity/UserIdentity.java b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/UserIdentity.java new file mode 100644 index 0000000..17eb4d2 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/entity/UserIdentity.java @@ -0,0 +1,55 @@ +package de.platesoft.auth.entity; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.envers.Audited; + +import java.time.Instant; +import java.util.UUID; + +/** + * Provider-agnostic identity link: one row per (provider, subject) pair, many per user. + */ +@Entity +@Table(name = "user_identities", uniqueConstraints = { + @UniqueConstraint(name = "uq_user_identities_provider_subject", columnNames = {"provider", "subject"}) +}) +@Audited +@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder +public class UserIdentity { + + @Id + @Column(columnDefinition = "uuid") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(nullable = false, length = 32) + private String provider; + + @Column(nullable = false, length = 255) + private String subject; + + @Column(nullable = false, length = 255) + private String email; + + @Column(name = "tenant_id", length = 64) + private String tenantId; + + @Column(name = "linked_at", nullable = false) + private Instant linkedAt; + + @Column(name = "last_login_at") + private Instant lastLoginAt; + + @Version + private Long version; + + @PrePersist + void prePersist() { + if (id == null) id = UUID.randomUUID(); + if (linkedAt == null) linkedAt = Instant.now(); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/filter/JwtAuthenticationFilter.java b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/JwtAuthenticationFilter.java new file mode 100644 index 0000000..c12d8cc --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/JwtAuthenticationFilter.java @@ -0,0 +1,47 @@ +package de.platesoft.auth.filter; + +import de.platesoft.auth.service.JwtService; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.List; +import java.util.UUID; + +@Slf4j +@RequiredArgsConstructor +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + String authHeader = request.getHeader("Authorization"); + if (authHeader != null && authHeader.startsWith("Bearer ")) { + String token = authHeader.substring(7); + if (jwtService.isTokenValid(token)) { + UUID userId = jwtService.extractUserId(token); + String email = jwtService.extractEmail(token); + String role = jwtService.extractRole(token); + + var auth = new UsernamePasswordAuthenticationToken( + userId.toString(), + null, + List.of(new SimpleGrantedAuthority(role)) + ); + auth.setDetails(email); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + filterChain.doFilter(request, response); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/AccessRequestRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/AccessRequestRepository.java new file mode 100644 index 0000000..34eef97 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/AccessRequestRepository.java @@ -0,0 +1,15 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.AccessRequest; +import de.platesoft.auth.entity.AccessRequestStatus; +import de.platesoft.auth.entity.OrgType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.UUID; + +public interface AccessRequestRepository extends JpaRepository { + List findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, AccessRequestStatus status); + List findByRequesterIdAndStatus(UUID requesterId, AccessRequestStatus status); + long countByRequesterIdAndStatus(UUID requesterId, AccessRequestStatus status); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/InvitationRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/InvitationRepository.java new file mode 100644 index 0000000..d2510b8 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/InvitationRepository.java @@ -0,0 +1,16 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.Invitation; +import de.platesoft.auth.entity.InvitationStatus; +import de.platesoft.auth.entity.OrgType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface InvitationRepository extends JpaRepository { + Optional findByToken(String token); + List findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, InvitationStatus status); + List findByEmailAndStatus(String email, InvitationStatus status); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/LoginEventRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/LoginEventRepository.java new file mode 100644 index 0000000..0244fc4 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/LoginEventRepository.java @@ -0,0 +1,7 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.LoginEvent; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface LoginEventRepository extends JpaRepository { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/MembershipRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/MembershipRepository.java new file mode 100644 index 0000000..bf4d8e3 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/MembershipRepository.java @@ -0,0 +1,16 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.Membership; +import de.platesoft.auth.entity.MembershipStatus; +import de.platesoft.auth.entity.OrgType; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +public interface MembershipRepository extends JpaRepository { + Optional findByUserIdAndOrgTypeAndOrgId(UUID userId, OrgType orgType, UUID orgId); + List findByUserIdAndStatus(UUID userId, MembershipStatus status); + List findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, MembershipStatus status); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/RefreshTokenRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/RefreshTokenRepository.java new file mode 100644 index 0000000..a526b77 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/RefreshTokenRepository.java @@ -0,0 +1,12 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.RefreshToken; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface RefreshTokenRepository extends JpaRepository { + Optional findByToken(String token); + void deleteByUserId(UUID userId); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserIdentityRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserIdentityRepository.java new file mode 100644 index 0000000..6979609 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserIdentityRepository.java @@ -0,0 +1,11 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.UserIdentity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserIdentityRepository extends JpaRepository { + Optional findByProviderAndSubject(String provider, String subject); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserRepository.java b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserRepository.java new file mode 100644 index 0000000..2556983 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/repository/UserRepository.java @@ -0,0 +1,12 @@ +package de.platesoft.auth.repository; + +import de.platesoft.auth.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; +import java.util.UUID; + +public interface UserRepository extends JpaRepository { + Optional findByEmail(String email); + boolean existsByEmail(String email); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java new file mode 100644 index 0000000..820b7b7 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/ExchangeService.java @@ -0,0 +1,162 @@ +package de.platesoft.auth.service; + +import com.fasterxml.jackson.databind.ObjectMapper; +import de.platesoft.auth.PlateAuthProperties; +import de.platesoft.auth.dto.ExchangePayload; +import de.platesoft.auth.dto.TokenResponse; +import de.platesoft.auth.entity.*; +import de.platesoft.auth.repository.UserIdentityRepository; +import de.platesoft.auth.repository.UserRepository; +import de.platesoft.auth.spi.OnboardingHook; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.time.Instant; +import java.util.HexFormat; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; + +/** + * Verifies the HMAC-SHA256 exchange envelope from NextAuth and provides replay protection. + * The nonce store is in-memory and single-instance only (see Architecture.md § 5 note). + */ +@Slf4j +@Service +public class ExchangeService { + + private final String secret; + private final long maxAgeSeconds; + private final long nonceTtlSeconds; + private final ObjectMapper mapper; + private final JwtService jwtService; + private final UserRepository userRepository; + private final UserIdentityRepository identityRepository; + private final OnboardingHook onboardingHook; + private final LoginEventService loginEventService; + + private final ConcurrentMap seenNonces = new ConcurrentHashMap<>(); + + public ExchangeService( + PlateAuthProperties props, + ObjectMapper mapper, + JwtService jwtService, + UserRepository userRepository, + UserIdentityRepository identityRepository, + OnboardingHook onboardingHook, + LoginEventService loginEventService) { + this.secret = props.getExchange().getSecret(); + this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds(); + this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds(); + this.mapper = mapper; + this.jwtService = jwtService; + this.userRepository = userRepository; + this.identityRepository = identityRepository; + this.onboardingHook = onboardingHook; + this.loginEventService = loginEventService; + } + + /** + * Verify HMAC signature, check envelope age and nonce uniqueness, + * then find-or-create user and issue tokens. + */ + @Transactional + public TokenResponse verifyAndExchange(String body, String signatureHex, String ipAddress, String userAgent) { + if (signatureHex == null || signatureHex.isBlank()) { + throw new SecurityException("Missing X-Exchange-Signature"); + } + String expected = hmacSha256Hex(body); + if (!constantTimeEquals(expected, signatureHex)) { + log.warn("Exchange HMAC mismatch — possible tampering or key mismatch"); + throw new SecurityException("Invalid exchange signature"); + } + + ExchangePayload payload; + try { + payload = mapper.readValue(body, ExchangePayload.class); + } catch (Exception e) { + throw new SecurityException("Malformed exchange payload"); + } + + long now = Instant.now().getEpochSecond(); + if (Math.abs(now - payload.iat()) > maxAgeSeconds) { + throw new SecurityException("Exchange envelope expired"); + } + + Long prev = seenNonces.putIfAbsent(payload.nonce(), now); + if (prev != null) { + throw new SecurityException("Replayed nonce"); + } + gcNonces(now); + + // Find or create user + identity + User user = findOrCreateUser(payload); + + // Record login event + loginEventService.recordSuccess(user, payload.provider(), ipAddress, userAgent); + + return jwtService.issueTokensFor(user); + } + + private User findOrCreateUser(ExchangePayload payload) { + var existingIdentity = identityRepository.findByProviderAndSubject( + payload.provider(), payload.providerSubject()); + + if (existingIdentity.isPresent()) { + UserIdentity identity = existingIdentity.get(); + identity.setLastLoginAt(Instant.now()); + User user = identity.getUser(); + user.setLastProvider(payload.provider()); + onboardingHook.onSubsequentSignIn(user, LoginProvider.valueOf(payload.provider().toUpperCase())); + return user; + } + + // New user + User user = userRepository.findByEmail(payload.email()) + .orElseGet(() -> userRepository.save(User.builder() + .email(payload.email()) + .firstName(payload.name() != null ? payload.name().split(" ")[0] : "") + .lastName(payload.name() != null && payload.name().contains(" ") + ? payload.name().substring(payload.name().indexOf(' ') + 1) : "") + .role(Role.ROLE_USER) + .active(true) + .lastProvider(payload.provider()) + .build())); + + identityRepository.save(UserIdentity.builder() + .user(user) + .provider(payload.provider()) + .subject(payload.providerSubject()) + .email(payload.email()) + .build()); + + onboardingHook.onFirstSignIn(user, LoginProvider.valueOf(payload.provider().toUpperCase())); + return user; + } + + private String hmacSha256Hex(String body) { + try { + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); + return HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8))); + } catch (Exception e) { + throw new IllegalStateException("HMAC computation failed", e); + } + } + + /** Constant-time comparison using MessageDigest.isEqual */ + private boolean constantTimeEquals(String a, String b) { + byte[] aBytes = a.getBytes(StandardCharsets.UTF_8); + byte[] bBytes = b.getBytes(StandardCharsets.UTF_8); + return MessageDigest.isEqual(aBytes, bBytes); + } + + private void gcNonces(long now) { + seenNonces.entrySet().removeIf(e -> now - e.getValue() > nonceTtlSeconds); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/JwtService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/JwtService.java new file mode 100644 index 0000000..7e0a354 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/JwtService.java @@ -0,0 +1,96 @@ +package de.platesoft.auth.service; + +import de.platesoft.auth.PlateAuthProperties; +import de.platesoft.auth.dto.TokenResponse; +import de.platesoft.auth.entity.User; +import io.jsonwebtoken.*; +import io.jsonwebtoken.security.Keys; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import javax.crypto.SecretKey; +import java.nio.charset.StandardCharsets; +import java.util.Date; +import java.util.Map; +import java.util.UUID; + +@Slf4j +@Service +public class JwtService { + + private final SecretKey key; + private final long accessExpirationMs; + private final long refreshExpirationMs; + private final String issuer; + + public JwtService(PlateAuthProperties props) { + this.key = Keys.hmacShaKeyFor(props.getJwt().getSecret().getBytes(StandardCharsets.UTF_8)); + this.accessExpirationMs = props.getJwt().getAccessExpiration().toMillis(); + this.refreshExpirationMs = props.getJwt().getRefreshExpiration().toMillis(); + this.issuer = props.getJwt().getIssuer(); + } + + public String generateAccessToken(UUID userId, String email, String role) { + return Jwts.builder() + .issuer(issuer) + .subject(userId.toString()) + .claims(Map.of("email", email, "role", role)) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + accessExpirationMs)) + .signWith(key) + .compact(); + } + + public String generateRefreshToken(UUID userId) { + return Jwts.builder() + .issuer(issuer) + .subject(userId.toString()) + .claim("type", "refresh") + .id(UUID.randomUUID().toString()) + .issuedAt(new Date()) + .expiration(new Date(System.currentTimeMillis() + refreshExpirationMs)) + .signWith(key) + .compact(); + } + + public TokenResponse issueTokensFor(User user) { + String access = generateAccessToken(user.getId(), user.getEmail(), user.getRole().name()); + String refresh = generateRefreshToken(user.getId()); + return new TokenResponse(access, refresh, accessExpirationMs / 1000); + } + + public long getRefreshExpirationMs() { + return refreshExpirationMs; + } + + public Claims extractClaims(String token) { + return Jwts.parser() + .verifyWith(key) + .requireIssuer(issuer) + .build() + .parseSignedClaims(token) + .getPayload(); + } + + public UUID extractUserId(String token) { + return UUID.fromString(extractClaims(token).getSubject()); + } + + public String extractEmail(String token) { + return extractClaims(token).get("email", String.class); + } + + public String extractRole(String token) { + return extractClaims(token).get("role", String.class); + } + + public boolean isTokenValid(String token) { + try { + extractClaims(token); + return true; + } catch (JwtException | IllegalArgumentException e) { + log.debug("Token validation failed: {}", e.getMessage()); + return false; + } + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/LoginEventService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/LoginEventService.java new file mode 100644 index 0000000..67289bb --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/LoginEventService.java @@ -0,0 +1,42 @@ +package de.platesoft.auth.service; + +import de.platesoft.auth.entity.LoginEvent; +import de.platesoft.auth.entity.User; +import de.platesoft.auth.repository.LoginEventRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; + +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class LoginEventService { + + private final LoginEventRepository repo; + + @Async + public void recordSuccess(User user, String provider, String ipAddress, String userAgent) { + repo.save(LoginEvent.builder() + .userId(user.getId()) + .email(user.getEmail()) + .provider(provider) + .outcome("SUCCESS") + .ipAddress(ipAddress) + .userAgent(userAgent) + .build()); + } + + @Async + public void recordFailure(String email, String provider, String outcome, String ipAddress, String userAgent) { + repo.save(LoginEvent.builder() + .email(email) + .provider(provider) + .outcome(outcome) + .ipAddress(ipAddress) + .userAgent(userAgent) + .build()); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/MembershipService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/MembershipService.java new file mode 100644 index 0000000..d7e2425 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/MembershipService.java @@ -0,0 +1,116 @@ +package de.platesoft.auth.service; + +import de.platesoft.auth.entity.*; +import de.platesoft.auth.repository.MembershipRepository; +import de.platesoft.auth.spi.OrgValidator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class MembershipService { + + private final MembershipRepository repo; + private final OrgValidator orgValidator; + + @Transactional + public Membership grant(User user, OrgType orgType, UUID orgId, MembershipRole role, + UUID grantedBy, String reason) { + // Validate org exists via SPI + if (!orgValidator.exists(orgType, orgId)) { + throw new IllegalArgumentException( + "OrgValidator rejected org: " + orgType + "/" + orgId); + } + + Optional existing = repo.findByUserIdAndOrgTypeAndOrgId(user.getId(), orgType, orgId); + if (existing.isPresent()) { + Membership m = existing.get(); + if (m.getStatus() == MembershipStatus.ACTIVE) { + if (rank(role) > rank(m.getRole())) { + log.info("Upgrading membership user={} org={} {}→{}", user.getId(), orgId, m.getRole(), role); + m.setRole(role); + } + return m; + } + m.setStatus(MembershipStatus.ACTIVE); + m.setRole(role); + m.setRevokedAt(null); + m.setRevokedBy(null); + m.setGrantedBy(grantedBy); + m.setGrantReason(reason); + return m; + } + return repo.save(Membership.builder() + .user(user) + .orgType(orgType) + .orgId(orgId) + .role(role) + .status(MembershipStatus.ACTIVE) + .grantedBy(grantedBy) + .grantReason(reason) + .build()); + } + + @Transactional + public void revoke(UUID membershipId, UUID revokedBy) { + Membership m = repo.findById(membershipId) + .orElseThrow(() -> new IllegalArgumentException("Membership not found: " + membershipId)); + if (m.getStatus() == MembershipStatus.REVOKED) return; + m.setStatus(MembershipStatus.REVOKED); + m.setRevokedAt(Instant.now()); + m.setRevokedBy(revokedBy); + } + + @Transactional(readOnly = true) + public List activeForUser(UUID userId) { + return repo.findByUserIdAndStatus(userId, MembershipStatus.ACTIVE); + } + + @Transactional(readOnly = true) + public Optional resolve(UUID userId, OrgType orgType, UUID orgId) { + return repo.findByUserIdAndOrgTypeAndOrgId(userId, orgType, orgId) + .filter(m -> m.getStatus() == MembershipStatus.ACTIVE); + } + + @Transactional(readOnly = true) + public boolean isOrgAdmin(UUID userId, OrgType orgType, UUID orgId) { + return repo.findByUserIdAndOrgTypeAndOrgId(userId, orgType, orgId) + .filter(m -> m.getStatus() == MembershipStatus.ACTIVE) + .map(m -> rank(m.getRole()) >= rank(MembershipRole.ADMIN)) + .orElse(false); + } + + public void assertAdminOf(UUID userId, OrgType orgType, UUID orgId) { + if (!isOrgAdmin(userId, orgType, orgId)) { + throw new AccessDeniedException( + "User " + userId + " is not admin of " + orgType + "/" + orgId); + } + } + + @Transactional(readOnly = true) + public List adminsByOrg(OrgType orgType, UUID orgId) { + return repo.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, MembershipStatus.ACTIVE) + .stream() + .filter(m -> rank(m.getRole()) >= rank(MembershipRole.ADMIN)) + .toList(); + } + + private int rank(MembershipRole r) { + return switch (r) { + case OWNER -> 5; + case ADMIN -> 4; + case INSPECTOR -> 3; + case VIEWER -> 2; + case MEMBER -> 1; + }; + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/AccessRequestMailer.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/AccessRequestMailer.java new file mode 100644 index 0000000..0d8a230 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/AccessRequestMailer.java @@ -0,0 +1,12 @@ +package de.platesoft.auth.spi; + +import de.platesoft.auth.entity.AccessRequest; + +/** + * Notifies admins of new access requests and requesters of decisions. + * The default implementation logs notifications at INFO level. + */ +public interface AccessRequestMailer { + void notifyAdmins(AccessRequest request); + void notifyRequester(AccessRequest request); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/InvitationMailer.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/InvitationMailer.java new file mode 100644 index 0000000..273e94b --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/InvitationMailer.java @@ -0,0 +1,10 @@ +package de.platesoft.auth.spi; + +import de.platesoft.auth.entity.Invitation; + +/** + * Sends invitation emails. The default implementation logs the accept URL at INFO level. + */ +public interface InvitationMailer { + void sendInvitation(Invitation invitation, String acceptUrl); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OnboardingHook.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OnboardingHook.java new file mode 100644 index 0000000..1898640 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OnboardingHook.java @@ -0,0 +1,16 @@ +package de.platesoft.auth.spi; + +import de.platesoft.auth.entity.LoginProvider; +import de.platesoft.auth.entity.User; + +/** + * Called on first and subsequent sign-ins. Consumers wire their T3 onboarding logic here. + * The default implementation is a no-op. + */ +public interface OnboardingHook { + void onFirstSignIn(User user, LoginProvider provider); + + default void onSubsequentSignIn(User user, LoginProvider provider) { + // no-op by default + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgDisplayNameResolver.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgDisplayNameResolver.java new file mode 100644 index 0000000..91f7274 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgDisplayNameResolver.java @@ -0,0 +1,13 @@ +package de.platesoft.auth.spi; + +import de.platesoft.auth.entity.OrgType; + +import java.util.UUID; + +/** + * Resolves a human-readable display name for an organization. + * Used in invitation emails, access request notifications, etc. + */ +public interface OrgDisplayNameResolver { + String displayName(OrgType type, UUID orgId); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgValidator.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgValidator.java new file mode 100644 index 0000000..77f410c --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/OrgValidator.java @@ -0,0 +1,19 @@ +package de.platesoft.auth.spi; + +import de.platesoft.auth.entity.OrgType; + +import java.util.UUID; + +/** + * Validates that a given (org_type, org_id) pair represents a real organization + * in the consuming application's domain. + * + *

plate-auth calls this SPI whenever a membership is being granted or an invitation + * is being created. If this returns {@code false}, the operation is rejected.

+ * + *

The default implementation ({@code PermissiveOrgValidator}) always returns {@code true} + * and logs a WARN on every call. Override this bean in production.

+ */ +public interface OrgValidator { + boolean exists(OrgType type, UUID orgId); +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/DefaultOrgDisplayNameResolver.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/DefaultOrgDisplayNameResolver.java new file mode 100644 index 0000000..176ad4d --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/DefaultOrgDisplayNameResolver.java @@ -0,0 +1,17 @@ +package de.platesoft.auth.spi.defaults; + +import de.platesoft.auth.entity.OrgType; +import de.platesoft.auth.spi.OrgDisplayNameResolver; + +import java.util.UUID; + +/** + * Default display name resolver — returns type:orgId. + */ +public class DefaultOrgDisplayNameResolver implements OrgDisplayNameResolver { + + @Override + public String displayName(OrgType type, UUID orgId) { + return type + ":" + orgId.toString(); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingAccessRequestMailer.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingAccessRequestMailer.java new file mode 100644 index 0000000..7ac9b2a --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingAccessRequestMailer.java @@ -0,0 +1,25 @@ +package de.platesoft.auth.spi.defaults; + +import de.platesoft.auth.entity.AccessRequest; +import de.platesoft.auth.spi.AccessRequestMailer; +import lombok.extern.slf4j.Slf4j; + +/** + * Default AccessRequestMailer — logs notifications at INFO level. + */ +@Slf4j +public class LoggingAccessRequestMailer implements AccessRequestMailer { + + @Override + public void notifyAdmins(AccessRequest request) { + log.info("[plate-auth] Access request from user {} for {}/{} with role {}", + request.getRequester().getEmail(), request.getOrgType(), + request.getOrgId(), request.getRequestedRole()); + } + + @Override + public void notifyRequester(AccessRequest request) { + log.info("[plate-auth] Access request {} decided: {}", + request.getId(), request.getStatus()); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingInvitationMailer.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingInvitationMailer.java new file mode 100644 index 0000000..134fbab --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/LoggingInvitationMailer.java @@ -0,0 +1,19 @@ +package de.platesoft.auth.spi.defaults; + +import de.platesoft.auth.entity.Invitation; +import de.platesoft.auth.spi.InvitationMailer; +import lombok.extern.slf4j.Slf4j; + +/** + * Default InvitationMailer — logs the accept URL at INFO level. + */ +@Slf4j +public class LoggingInvitationMailer implements InvitationMailer { + + @Override + public void sendInvitation(Invitation invitation, String acceptUrl) { + log.info("[plate-auth] Invitation for {} to join {}/{} with role {}. Accept URL: {}", + invitation.getEmail(), invitation.getOrgType(), invitation.getOrgId(), + invitation.getRole(), acceptUrl); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/NoOpOnboardingHook.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/NoOpOnboardingHook.java new file mode 100644 index 0000000..d6fba06 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/NoOpOnboardingHook.java @@ -0,0 +1,16 @@ +package de.platesoft.auth.spi.defaults; + +import de.platesoft.auth.entity.LoginProvider; +import de.platesoft.auth.entity.User; +import de.platesoft.auth.spi.OnboardingHook; + +/** + * Default OnboardingHook — no-op. + */ +public class NoOpOnboardingHook implements OnboardingHook { + + @Override + public void onFirstSignIn(User user, LoginProvider provider) { + // no-op — consumers override to wire their onboarding logic + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/PermissiveOrgValidator.java b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/PermissiveOrgValidator.java new file mode 100644 index 0000000..9d9b926 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/spi/defaults/PermissiveOrgValidator.java @@ -0,0 +1,22 @@ +package de.platesoft.auth.spi.defaults; + +import de.platesoft.auth.entity.OrgType; +import de.platesoft.auth.spi.OrgValidator; +import lombok.extern.slf4j.Slf4j; + +import java.util.UUID; + +/** + * Default OrgValidator that accepts all (org_type, org_id) pairs. + * Logs a WARN on every call to make it impossible to miss in production. + * Override this bean to implement real validation. + */ +@Slf4j +public class PermissiveOrgValidator implements OrgValidator { + + @Override + public boolean exists(OrgType type, UUID orgId) { + log.warn("OrgValidator default permissive — override de.platesoft.auth.spi.OrgValidator bean before production. Called with ({}, {})", type, orgId); + return true; + } +} diff --git a/plate-auth-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/plate-auth-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports new file mode 100644 index 0000000..29268ee --- /dev/null +++ b/plate-auth-starter/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -0,0 +1 @@ +de.platesoft.auth.PlateAuthAutoConfiguration