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>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-mail</artifactId>
|
<artifactId>spring-boot-starter-mail</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.fasterxml.jackson.core</groupId>
|
||||||
|
<artifactId>jackson-databind</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Hibernate Envers (Audit) -->
|
<!-- Hibernate Envers (Audit) -->
|
||||||
<dependency>
|
<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