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.
This commit is contained in:
@@ -41,6 +41,10 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>com.fasterxml.jackson.core</groupId>
|
||||
<artifactId>jackson-databind</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Hibernate Envers (Audit) -->
|
||||
<dependency>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String> allowedOrigins = new ArrayList<>();
|
||||
private List<String> 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<TokenResponse> 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {}
|
||||
@@ -0,0 +1,7 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
public record TokenResponse(
|
||||
String accessToken,
|
||||
String refreshToken,
|
||||
long expiresIn
|
||||
) {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.platesoft.auth.entity;
|
||||
|
||||
public enum AccessRequestStatus { PENDING, APPROVED, DENIED, EXPIRED }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.platesoft.auth.entity;
|
||||
|
||||
public enum InvitationStatus { PENDING, ACCEPTED, REVOKED, EXPIRED }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.platesoft.auth.entity;
|
||||
|
||||
public enum LoginProvider { GOOGLE, MICROSOFT, EMAIL, PASSWORD }
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package de.platesoft.auth.entity;
|
||||
|
||||
public enum MembershipRole {
|
||||
OWNER,
|
||||
ADMIN,
|
||||
INSPECTOR,
|
||||
VIEWER,
|
||||
MEMBER
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.platesoft.auth.entity;
|
||||
|
||||
public enum MembershipStatus { ACTIVE, REVOKED, PENDING }
|
||||
@@ -0,0 +1,3 @@
|
||||
package de.platesoft.auth.entity;
|
||||
|
||||
public enum OrgType { COMPANY, CLUB, WORKSPACE }
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
+47
@@ -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);
|
||||
}
|
||||
}
|
||||
+15
@@ -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<AccessRequest, UUID> {
|
||||
List<AccessRequest> findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, AccessRequestStatus status);
|
||||
List<AccessRequest> findByRequesterIdAndStatus(UUID requesterId, AccessRequestStatus status);
|
||||
long countByRequesterIdAndStatus(UUID requesterId, AccessRequestStatus status);
|
||||
}
|
||||
+16
@@ -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<Invitation, UUID> {
|
||||
Optional<Invitation> findByToken(String token);
|
||||
List<Invitation> findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, InvitationStatus status);
|
||||
List<Invitation> findByEmailAndStatus(String email, InvitationStatus status);
|
||||
}
|
||||
+7
@@ -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<LoginEvent, Long> {
|
||||
}
|
||||
+16
@@ -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<Membership, UUID> {
|
||||
Optional<Membership> findByUserIdAndOrgTypeAndOrgId(UUID userId, OrgType orgType, UUID orgId);
|
||||
List<Membership> findByUserIdAndStatus(UUID userId, MembershipStatus status);
|
||||
List<Membership> findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, MembershipStatus status);
|
||||
}
|
||||
+12
@@ -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<RefreshToken, UUID> {
|
||||
Optional<RefreshToken> findByToken(String token);
|
||||
void deleteByUserId(UUID userId);
|
||||
}
|
||||
+11
@@ -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<UserIdentity, UUID> {
|
||||
Optional<UserIdentity> findByProviderAndSubject(String provider, String subject);
|
||||
}
|
||||
@@ -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<User, UUID> {
|
||||
Optional<User> findByEmail(String email);
|
||||
boolean existsByEmail(String email);
|
||||
}
|
||||
@@ -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<String, Long> 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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<Membership> 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<Membership> activeForUser(UUID userId) {
|
||||
return repo.findByUserIdAndStatus(userId, MembershipStatus.ACTIVE);
|
||||
}
|
||||
|
||||
@Transactional(readOnly = true)
|
||||
public Optional<Membership> 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<Membership> 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;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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.
|
||||
*
|
||||
* <p>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.</p>
|
||||
*
|
||||
* <p>The default implementation ({@code PermissiveOrgValidator}) always returns {@code true}
|
||||
* and logs a WARN on every call. Override this bean in production.</p>
|
||||
*/
|
||||
public interface OrgValidator {
|
||||
boolean exists(OrgType type, UUID orgId);
|
||||
}
|
||||
+17
@@ -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();
|
||||
}
|
||||
}
|
||||
+25
@@ -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());
|
||||
}
|
||||
}
|
||||
+19
@@ -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);
|
||||
}
|
||||
}
|
||||
+16
@@ -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
|
||||
}
|
||||
}
|
||||
+22
@@ -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;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
de.platesoft.auth.PlateAuthAutoConfiguration
|
||||
Reference in New Issue
Block a user