Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bfcfe83199 | |||
| b43ab5e02c |
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Security / Correctness — Review-v2 blockers fixed
|
||||
- **B1:** `SecurityConfig` `SecurityFilterChain` is now `securityMatcher`-scoped to plate-auth's own
|
||||
endpoints (`/api/auth/**`, `/api/invitations/**`, `/api/access-requests/**`, `/api/admin/**`, `/api/me`,
|
||||
`/api/memberships/**`). Previously an unscoped `@Order(-100)` chain with `anyRequest().authenticated()`
|
||||
would hijack the consuming app's own routes. (panel B1)
|
||||
- **B2:** Removed `@ComponentScan(basePackages="de.platesoft.auth")` from `PlateAuthAutoConfiguration`
|
||||
(auto-configuration anti-pattern per Spring Boot guidance). Replaced with explicit `@Import` of the
|
||||
concrete `@Configuration` classes + `@Service`/`@RestController` components. (panel B2)
|
||||
- **B4:** CORS now fails closed by default. Empty `plate.auth.cors.allowed-origins` disables CORS for
|
||||
plate-auth endpoints (same-origin only) instead of defaulting to `allowedOriginPatterns("*")`. (panel B4)
|
||||
- **B5:** Removed dead `RefreshToken` entity + `RefreshTokenRepository`. v0.1 issues stateless JWT refresh
|
||||
tokens (per the documented threat model); rotation/family-tracking is deferred to v0.3. (panel B5)
|
||||
- **W-A:** Documented the `OnboardingHook` transaction contract (hooks run inside the exchange
|
||||
transaction; keep them fast + idempotent).
|
||||
|
||||
### Added
|
||||
- Initial project scaffold (W1)
|
||||
- Maven parent POM with `${revision}` CI-friendly versioning
|
||||
|
||||
@@ -1,5 +1,20 @@
|
||||
package de.platesoft.auth;
|
||||
|
||||
import de.platesoft.auth.config.PlateAuthFlywayConfig;
|
||||
import de.platesoft.auth.config.PlateAuthExceptionHandler;
|
||||
import de.platesoft.auth.config.SecurityConfig;
|
||||
import de.platesoft.auth.controller.AdminAuditController;
|
||||
import de.platesoft.auth.controller.AuthController;
|
||||
import de.platesoft.auth.controller.AccessRequestController;
|
||||
import de.platesoft.auth.controller.InvitationController;
|
||||
import de.platesoft.auth.controller.OAuthController;
|
||||
import de.platesoft.auth.service.AccessRequestService;
|
||||
import de.platesoft.auth.service.AuthService;
|
||||
import de.platesoft.auth.service.ExchangeService;
|
||||
import de.platesoft.auth.service.InvitationService;
|
||||
import de.platesoft.auth.service.JwtService;
|
||||
import de.platesoft.auth.service.LoginEventService;
|
||||
import de.platesoft.auth.service.MembershipService;
|
||||
import de.platesoft.auth.spi.*;
|
||||
import de.platesoft.auth.spi.defaults.*;
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
@@ -8,13 +23,45 @@ 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.context.annotation.Import;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
|
||||
import org.springframework.scheduling.annotation.EnableAsync;
|
||||
|
||||
/**
|
||||
* plate-auth auto-configuration.
|
||||
*
|
||||
* <p><b>Review-v2 fix B2 — no {@code @ComponentScan}.</b> Spring Boot's auto-configuration guidance is
|
||||
* explicit that starter auto-config classes must <em>not</em> use {@code @ComponentScan}. It previously
|
||||
* swept the whole {@code de.platesoft.auth} package, which is unpredictable in a consumer and can
|
||||
* double-instantiate beans. Instead we now {@link Import @Import} the concrete {@code @Configuration}
|
||||
* classes explicitly ({@link SecurityConfig}, {@link PlateAuthFlywayConfig},
|
||||
* {@link PlateAuthExceptionHandler}) and declare every SPI default as an explicit {@code @Bean} below.
|
||||
*
|
||||
* <p>Every concrete {@code @Service} / {@code @RestController} class the starter publishes is also
|
||||
* explicitly {@link Import @Import}ed here so the starter is <em>self-sufficient</em>: it does not rely
|
||||
* on the consuming application scanning {@code de.platesoft.auth}. New services/controllers added in
|
||||
* later workstreams must be appended to the {@code @Import} list (this is the trade-off vs the old
|
||||
* broad scan — it is intentional and keeps the bean surface explicit and predictable).
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(PlateAuthProperties.class)
|
||||
@ComponentScan(basePackages = "de.platesoft.auth")
|
||||
@Import({
|
||||
SecurityConfig.class,
|
||||
PlateAuthFlywayConfig.class,
|
||||
PlateAuthExceptionHandler.class,
|
||||
ExchangeService.class,
|
||||
JwtService.class,
|
||||
LoginEventService.class,
|
||||
MembershipService.class,
|
||||
AuthService.class,
|
||||
InvitationService.class,
|
||||
AccessRequestService.class,
|
||||
OAuthController.class,
|
||||
AuthController.class,
|
||||
InvitationController.class,
|
||||
AccessRequestController.class,
|
||||
AdminAuditController.class
|
||||
})
|
||||
@AutoConfigurationPackage(basePackages = "de.platesoft.auth.entity")
|
||||
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
|
||||
@EnableAsync
|
||||
|
||||
@@ -2,7 +2,10 @@ package de.platesoft.auth.config;
|
||||
|
||||
import de.platesoft.auth.PlateAuthProperties;
|
||||
import de.platesoft.auth.filter.JwtAuthenticationFilter;
|
||||
import de.platesoft.auth.filter.OrgContextResolver;
|
||||
import de.platesoft.auth.service.JwtService;
|
||||
import de.platesoft.auth.service.MembershipService;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.core.annotation.Order;
|
||||
@@ -20,12 +23,32 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Security configuration for plate-auth. Registers a SecurityFilterChain
|
||||
* that handles JWT validation and allows public access to auth endpoints.
|
||||
* Security configuration for plate-auth.
|
||||
*
|
||||
* <p><b>Review-v2 fix B1 — scoped chain.</b> This {@link SecurityFilterChain} is bound with
|
||||
* {@code securityMatcher(...)} to <em>only</em> the endpoints plate-auth owns. This is mandatory for a
|
||||
* starter: an unscoped chain at a high priority (previously {@code @Order(-100)} with
|
||||
* {@code anyRequest().authenticated()}) would otherwise hijack the consuming application's own
|
||||
* {@code SecurityFilterChain} and override its public routes. Consumers keep their own default
|
||||
* (catch-all) chain for every path outside {@link #PLATE_AUTH_PATHS}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Configuration
|
||||
public class SecurityConfig {
|
||||
|
||||
/**
|
||||
* Paths owned by plate-auth. The library's {@link SecurityFilterChain} only matches these.
|
||||
* Everything else falls through to the consuming application's own security configuration.
|
||||
*/
|
||||
public static final String[] PLATE_AUTH_PATHS = {
|
||||
"/api/auth/**",
|
||||
"/api/invitations/**",
|
||||
"/api/access-requests/**",
|
||||
"/api/admin/**",
|
||||
"/api/me",
|
||||
"/api/memberships/**"
|
||||
};
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder plateAuthPasswordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
@@ -37,12 +60,26 @@ public class SecurityConfig {
|
||||
}
|
||||
|
||||
@Bean
|
||||
@Order(-100)
|
||||
public OrgContextResolver orgContextResolver(MembershipService membershipService) {
|
||||
return new OrgContextResolver(membershipService);
|
||||
}
|
||||
|
||||
/**
|
||||
* plate-auth's security chain, scoped to {@link #PLATE_AUTH_PATHS} only.
|
||||
*
|
||||
* <p>Carries an explicit {@code securityMatcher} so it never competes with the consuming app's
|
||||
* own (catch-all) {@code SecurityFilterChain}. Consumers retain full control of every path
|
||||
* plate-auth does not own.
|
||||
*/
|
||||
@Bean
|
||||
@Order(100)
|
||||
public SecurityFilterChain plateAuthSecurityFilterChain(
|
||||
HttpSecurity http,
|
||||
JwtAuthenticationFilter jwtFilter,
|
||||
OrgContextResolver orgContextResolver,
|
||||
PlateAuthProperties props) throws Exception {
|
||||
http
|
||||
.securityMatcher(PLATE_AUTH_PATHS)
|
||||
.csrf(AbstractHttpConfigurer::disable)
|
||||
.cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
@@ -53,27 +90,38 @@ public class SecurityConfig {
|
||||
"/api/auth/register",
|
||||
"/api/auth/refresh",
|
||||
"/api/auth/config",
|
||||
"/actuator/health"
|
||||
"/api/access-requests"
|
||||
).permitAll()
|
||||
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
|
||||
.anyRequest().authenticated()
|
||||
)
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
|
||||
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
|
||||
.addFilterAfter(orgContextResolver, JwtAuthenticationFilter.class);
|
||||
return http.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* CORS configuration source.
|
||||
*
|
||||
* <p><b>Review-v2 fix B4 — fail closed by default.</b> When {@code plate.auth.cors.allowed-origins}
|
||||
* is empty (the default), CORS is <em>disabled</em> for plate-auth's endpoints (same-origin only).
|
||||
* An authentication library must never default to {@code allowedOriginPatterns("*")}. Consumers
|
||||
* opt into cross-origin access by listing their origins explicitly.
|
||||
*/
|
||||
private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
if (props.getCors().getAllowedOrigins().isEmpty()) {
|
||||
config.setAllowedOriginPatterns(List.of("*"));
|
||||
} else {
|
||||
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
|
||||
config.setAllowCredentials(true);
|
||||
// Fail closed: register no CORS rule → Spring rejects cross-origin requests to auth paths.
|
||||
log.warn("plate.auth.cors.allowed-origins is empty — CORS disabled for plate-auth endpoints "
|
||||
+ "(same-origin only). Set allowed-origins to enable cross-origin access.");
|
||||
return source;
|
||||
}
|
||||
CorsConfiguration config = new CorsConfiguration();
|
||||
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
|
||||
config.setAllowCredentials(true);
|
||||
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
|
||||
config.setAllowedHeaders(List.of("*"));
|
||||
config.setMaxAge(3600L);
|
||||
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||
source.registerCorsConfiguration("/**", config);
|
||||
return source;
|
||||
}
|
||||
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
package de.platesoft.auth.controller;
|
||||
|
||||
import de.platesoft.auth.dto.CreateAccessRequestRequest;
|
||||
import de.platesoft.auth.dto.ReviewAccessRequestRequest;
|
||||
import de.platesoft.auth.entity.AccessRequest;
|
||||
import de.platesoft.auth.entity.Membership;
|
||||
import de.platesoft.auth.entity.OrgType;
|
||||
import de.platesoft.auth.entity.User;
|
||||
import de.platesoft.auth.repository.UserRepository;
|
||||
import de.platesoft.auth.service.AccessRequestService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for self-service access requests.
|
||||
*
|
||||
* <p>Endpoints under {@code /api/access-requests/**}:
|
||||
* <ul>
|
||||
* <li>{@code POST /api/access-requests} — submit access request (authenticated, no membership required)</li>
|
||||
* <li>{@code POST /api/access-requests/{id}/approve} — approve (admin)</li>
|
||||
* <li>{@code POST /api/access-requests/{id}/deny} — deny (admin)</li>
|
||||
* <li>{@code GET /api/access-requests} — list pending for an org (admin)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/access-requests")
|
||||
@RequiredArgsConstructor
|
||||
public class AccessRequestController {
|
||||
|
||||
private final AccessRequestService accessRequestService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<AccessRequest> submit(@Valid @RequestBody CreateAccessRequestRequest req) {
|
||||
UUID userId = getCurrentUserId();
|
||||
User requester = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
AccessRequest request = accessRequestService.submitRequest(
|
||||
requester, req.orgType(), req.orgId(), req.requestedRole(), req.justification());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(request);
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/approve")
|
||||
public ResponseEntity<Map<String, String>> approve(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody(required = false) ReviewAccessRequestRequest req) {
|
||||
UUID reviewerId = getCurrentUserId();
|
||||
String reason = req != null ? req.decisionReason() : "Approved";
|
||||
Membership membership = accessRequestService.approveRequest(id, reviewerId, reason);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "approved",
|
||||
"membershipId", membership.getId().toString()
|
||||
));
|
||||
}
|
||||
|
||||
@PostMapping("/{id}/deny")
|
||||
public ResponseEntity<Map<String, String>> deny(
|
||||
@PathVariable UUID id,
|
||||
@RequestBody(required = false) ReviewAccessRequestRequest req) {
|
||||
UUID reviewerId = getCurrentUserId();
|
||||
String reason = req != null ? req.decisionReason() : "Denied";
|
||||
accessRequestService.denyRequest(id, reviewerId, reason);
|
||||
return ResponseEntity.ok(Map.of("status", "denied"));
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<AccessRequest>> pending(
|
||||
@RequestParam OrgType orgType,
|
||||
@RequestParam UUID orgId) {
|
||||
return ResponseEntity.ok(accessRequestService.pendingForOrg(orgType, orgId));
|
||||
}
|
||||
|
||||
private UUID getCurrentUserId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) {
|
||||
throw new IllegalStateException("No authenticated user in SecurityContext");
|
||||
}
|
||||
return UUID.fromString(auth.getName());
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
package de.platesoft.auth.controller;
|
||||
|
||||
import de.platesoft.auth.entity.LoginEvent;
|
||||
import de.platesoft.auth.repository.LoginEventRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.data.domain.Sort;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
/**
|
||||
* Admin-only audit endpoints.
|
||||
*
|
||||
* <p>Endpoints under {@code /api/admin/**} — enforced by
|
||||
* {@code .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")} in {@link
|
||||
* de.platesoft.auth.config.SecurityConfig SecurityConfig}.
|
||||
*
|
||||
* <ul>
|
||||
* <li>{@code GET /api/admin/login-events} — paginated login event audit log</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><b>Note:</b> The Envers revision browser ({@code GET /api/admin/audit/revisions}) is
|
||||
* added in W11 alongside the {@code RevInfo}/{@code RevInfoListener} implementation.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/admin")
|
||||
@RequiredArgsConstructor
|
||||
public class AdminAuditController {
|
||||
|
||||
private final LoginEventRepository loginEventRepository;
|
||||
|
||||
/**
|
||||
* Paginated login event audit log. Newest first.
|
||||
*
|
||||
* @param page zero-based page index (default 0)
|
||||
* @param size page size (default 50, max 200)
|
||||
*/
|
||||
@GetMapping("/login-events")
|
||||
public ResponseEntity<Page<LoginEvent>> loginEvents(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size) {
|
||||
size = Math.min(size, 200); // cap to prevent excessive queries
|
||||
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
|
||||
return ResponseEntity.ok(loginEventRepository.findAll(pageable));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
package de.platesoft.auth.controller;
|
||||
|
||||
import de.platesoft.auth.PlateAuthProperties;
|
||||
import de.platesoft.auth.dto.*;
|
||||
import de.platesoft.auth.service.AuthService;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for password-based auth endpoints.
|
||||
*
|
||||
* <p>Endpoints mounted under {@code /api/auth/**} (scoped by plate-auth's SecurityFilterChain):
|
||||
* <ul>
|
||||
* <li>{@code POST /api/auth/login} — password login (public)</li>
|
||||
* <li>{@code POST /api/auth/register} — password registration (public, if enabled)</li>
|
||||
* <li>{@code POST /api/auth/refresh} — refresh token rotation (public)</li>
|
||||
* <li>{@code GET /api/auth/me} — current user + memberships (authenticated)</li>
|
||||
* <li>{@code GET /api/auth/config} — provider list + registration flag (public)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/auth")
|
||||
@RequiredArgsConstructor
|
||||
public class AuthController {
|
||||
|
||||
private final AuthService authService;
|
||||
private final PlateAuthProperties props;
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req,
|
||||
HttpServletRequest request) {
|
||||
TokenResponse tokens = authService.login(
|
||||
req.email(), req.password(),
|
||||
request.getRemoteAddr(), request.getHeader("User-Agent"));
|
||||
return ResponseEntity.ok(tokens);
|
||||
}
|
||||
|
||||
@PostMapping("/register")
|
||||
public ResponseEntity<TokenResponse> register(@Valid @RequestBody RegisterRequest req,
|
||||
HttpServletRequest request) {
|
||||
if (!props.getRegistration().isEnabled()) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
|
||||
}
|
||||
TokenResponse tokens = authService.register(
|
||||
req.email(), req.password(), req.firstName(), req.lastName(),
|
||||
request.getRemoteAddr(), request.getHeader("User-Agent"));
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(tokens);
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<TokenResponse> refresh(@Valid @RequestBody RefreshRequest req) {
|
||||
TokenResponse tokens = authService.refresh(req.refreshToken());
|
||||
return ResponseEntity.ok(tokens);
|
||||
}
|
||||
|
||||
@GetMapping("/me")
|
||||
public ResponseEntity<UserResponse> me() {
|
||||
UUID userId = getCurrentUserId();
|
||||
var info = authService.getCurrentUser(userId);
|
||||
return ResponseEntity.ok(UserResponse.from(info.user(), info.memberships()));
|
||||
}
|
||||
|
||||
@GetMapping("/config")
|
||||
public ResponseEntity<AuthConfigResponse> config() {
|
||||
List<AuthConfigResponse.ProviderInfo> providers = new ArrayList<>();
|
||||
providers.add(AuthConfigResponse.ProviderInfo.google());
|
||||
providers.add(AuthConfigResponse.ProviderInfo.microsoft(
|
||||
props.getProviders().getMicrosoft().isEnabled()));
|
||||
providers.add(AuthConfigResponse.ProviderInfo.emailMagicLink(
|
||||
props.getProviders().getEmailMagicLink().isEnabled()));
|
||||
|
||||
return ResponseEntity.ok(new AuthConfigResponse(
|
||||
props.getRegistration().isEnabled(),
|
||||
providers
|
||||
));
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Extract the authenticated user's UUID from the SecurityContext.
|
||||
* The {@link de.platesoft.auth.filter.JwtAuthenticationFilter} sets the principal
|
||||
* to the userId as a string.
|
||||
*/
|
||||
private UUID getCurrentUserId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) {
|
||||
throw new IllegalStateException("No authenticated user in SecurityContext");
|
||||
}
|
||||
return UUID.fromString(auth.getName());
|
||||
}
|
||||
|
||||
// ── Exception handlers ────────────────────────────────────────────────────
|
||||
|
||||
@ExceptionHandler(AuthService.BadCredentialsException.class)
|
||||
public ResponseEntity<String> handleBadCredentials(AuthService.BadCredentialsException e) {
|
||||
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalStateException.class)
|
||||
public ResponseEntity<String> handleIllegalState(IllegalStateException e) {
|
||||
// Registration disabled or email already in use
|
||||
if (e.getMessage().contains("Registration is disabled")) {
|
||||
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
|
||||
}
|
||||
return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package de.platesoft.auth.controller;
|
||||
|
||||
import de.platesoft.auth.dto.CreateInvitationRequest;
|
||||
import de.platesoft.auth.entity.Invitation;
|
||||
import de.platesoft.auth.entity.Membership;
|
||||
import de.platesoft.auth.entity.OrgType;
|
||||
import de.platesoft.auth.entity.User;
|
||||
import de.platesoft.auth.repository.UserRepository;
|
||||
import de.platesoft.auth.service.InvitationService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* REST controller for invitation management.
|
||||
*
|
||||
* <p>Endpoints under {@code /api/invitations/**}:
|
||||
* <ul>
|
||||
* <li>{@code POST /api/invitations} — create invitation (authenticated)</li>
|
||||
* <li>{@code POST /api/invitations/accept} — accept invitation (authenticated, with token)</li>
|
||||
* <li>{@code DELETE /api/invitations/{id}} — revoke invitation (authenticated)</li>
|
||||
* <li>{@code GET /api/invitations} — list pending invitations (authenticated)</li>
|
||||
* </ul>
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/invitations")
|
||||
@RequiredArgsConstructor
|
||||
public class InvitationController {
|
||||
|
||||
private final InvitationService invitationService;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Invitation> create(@Valid @RequestBody CreateInvitationRequest req) {
|
||||
UUID createdBy = getCurrentUserId();
|
||||
Invitation invitation = invitationService.createInvitation(
|
||||
req.email(), req.orgType(), req.orgId(), req.role(), createdBy);
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(invitation);
|
||||
}
|
||||
|
||||
@PostMapping("/accept")
|
||||
public ResponseEntity<Map<String, String>> accept(@RequestBody Map<String, String> body) {
|
||||
String token = body.get("token");
|
||||
if (token == null || token.isBlank()) {
|
||||
return ResponseEntity.badRequest().body(Map.of("error", "Missing invitation token"));
|
||||
}
|
||||
UUID userId = getCurrentUserId();
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found"));
|
||||
Membership membership = invitationService.acceptInvitation(token, user);
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"status", "accepted",
|
||||
"membershipId", membership.getId().toString()
|
||||
));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{id}")
|
||||
public ResponseEntity<Void> revoke(@PathVariable UUID id) {
|
||||
UUID revokedBy = getCurrentUserId();
|
||||
invitationService.revokeInvitation(id, revokedBy);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Invitation>> pending(
|
||||
@RequestParam OrgType orgType,
|
||||
@RequestParam UUID orgId) {
|
||||
return ResponseEntity.ok(invitationService.pendingForOrg(orgType, orgId));
|
||||
}
|
||||
|
||||
private UUID getCurrentUserId() {
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) {
|
||||
throw new IllegalStateException("No authenticated user in SecurityContext");
|
||||
}
|
||||
return UUID.fromString(auth.getName());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Response for {@code GET /api/auth/config} — public endpoint returning enabled providers
|
||||
* and registration flag. Consumed by the frontend to render login buttons.
|
||||
*/
|
||||
public record AuthConfigResponse(
|
||||
boolean registrationEnabled,
|
||||
List<ProviderInfo> providers
|
||||
) {
|
||||
public record ProviderInfo(
|
||||
String id,
|
||||
String name,
|
||||
boolean enabled
|
||||
) {
|
||||
public static ProviderInfo google() {
|
||||
return new ProviderInfo("google", "Google", true);
|
||||
}
|
||||
|
||||
public static ProviderInfo microsoft(boolean enabled) {
|
||||
return new ProviderInfo("microsoft", "Microsoft Entra ID", enabled);
|
||||
}
|
||||
|
||||
public static ProviderInfo emailMagicLink(boolean enabled) {
|
||||
return new ProviderInfo("email", "Email Magic Link", enabled);
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import de.platesoft.auth.entity.MembershipRole;
|
||||
import de.platesoft.auth.entity.OrgType;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/access-requests} — submit a self-service access request.
|
||||
*/
|
||||
public record CreateAccessRequestRequest(
|
||||
@NotNull OrgType orgType,
|
||||
@NotNull UUID orgId,
|
||||
@NotNull MembershipRole requestedRole,
|
||||
@Size(max = 500) String justification
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import de.platesoft.auth.entity.MembershipRole;
|
||||
import de.platesoft.auth.entity.OrgType;
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/invitations} — create a new invitation.
|
||||
*/
|
||||
public record CreateInvitationRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotNull OrgType orgType,
|
||||
@NotNull UUID orgId,
|
||||
@NotNull MembershipRole role
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/auth/login}.
|
||||
*/
|
||||
public record LoginRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/auth/refresh}.
|
||||
*/
|
||||
public record RefreshRequest(
|
||||
@NotBlank String refreshToken
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Email;
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/auth/register}.
|
||||
* Registration must be enabled via {@code plate.auth.registration.enabled=true}.
|
||||
*/
|
||||
public record RegisterRequest(
|
||||
@NotBlank @Email String email,
|
||||
@NotBlank @Size(min = 8, message = "Password must be at least 8 characters") String password,
|
||||
@NotBlank @Size(max = 100) String firstName,
|
||||
@NotBlank @Size(max = 100) String lastName
|
||||
) {
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.Size;
|
||||
|
||||
/**
|
||||
* Request body for {@code POST /api/access-requests/{id}/approve} and
|
||||
* {@code POST /api/access-requests/{id}/deny}.
|
||||
*/
|
||||
public record ReviewAccessRequestRequest(
|
||||
@Size(max = 500) String decisionReason
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
package de.platesoft.auth.dto;
|
||||
|
||||
import de.platesoft.auth.entity.Membership;
|
||||
import de.platesoft.auth.entity.Role;
|
||||
import de.platesoft.auth.entity.User;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Response for {@code GET /api/auth/me} — current user + active memberships.
|
||||
*/
|
||||
public record UserResponse(
|
||||
UUID id,
|
||||
String email,
|
||||
String firstName,
|
||||
String lastName,
|
||||
Role role,
|
||||
boolean active,
|
||||
String lastProvider,
|
||||
OffsetDateTime lastLogin,
|
||||
List<MembershipSummary> memberships
|
||||
) {
|
||||
public static UserResponse from(User user, List<Membership> memberships) {
|
||||
return new UserResponse(
|
||||
user.getId(),
|
||||
user.getEmail(),
|
||||
user.getFirstName(),
|
||||
user.getLastName(),
|
||||
user.getRole(),
|
||||
user.isActive(),
|
||||
user.getLastProvider(),
|
||||
user.getLastLogin(),
|
||||
memberships.stream().map(MembershipSummary::from).toList()
|
||||
);
|
||||
}
|
||||
|
||||
public record MembershipSummary(
|
||||
UUID id,
|
||||
String orgType,
|
||||
UUID orgId,
|
||||
String role,
|
||||
String status
|
||||
) {
|
||||
public static MembershipSummary from(Membership m) {
|
||||
return new MembershipSummary(
|
||||
m.getId(),
|
||||
m.getOrgType().name(),
|
||||
m.getOrgId(),
|
||||
m.getRole().name(),
|
||||
m.getStatus().name()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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,14 @@
|
||||
package de.platesoft.auth.filter;
|
||||
|
||||
import de.platesoft.auth.entity.OrgType;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Resolved org context for the current request.
|
||||
*
|
||||
* <p>Set by {@link OrgContextResolver} from the {@code X-Org-Id} header and stored in
|
||||
* {@link OrgContextHolder} for downstream services to read.
|
||||
*/
|
||||
public record OrgContext(OrgType orgType, UUID orgId) {
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package de.platesoft.auth.filter;
|
||||
|
||||
/**
|
||||
* Thread-local holder for the {@link OrgContext} resolved on the current request.
|
||||
*
|
||||
* <p>Set by {@link OrgContextResolver} and cleared after the request completes.
|
||||
* Downstream services read it via {@link #get()} to know which org the current
|
||||
* request is scoped to.
|
||||
*/
|
||||
public final class OrgContextHolder {
|
||||
|
||||
private static final ThreadLocal<OrgContext> HOLDER = new ThreadLocal<>();
|
||||
|
||||
private OrgContextHolder() {
|
||||
}
|
||||
|
||||
public static void set(OrgContext context) {
|
||||
HOLDER.set(context);
|
||||
}
|
||||
|
||||
public static OrgContext get() {
|
||||
return HOLDER.get();
|
||||
}
|
||||
|
||||
public static void clear() {
|
||||
HOLDER.remove();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
package de.platesoft.auth.filter;
|
||||
|
||||
import de.platesoft.auth.entity.Membership;
|
||||
import de.platesoft.auth.entity.OrgType;
|
||||
import de.platesoft.auth.service.MembershipService;
|
||||
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.core.Authentication;
|
||||
import org.springframework.security.core.context.SecurityContextHolder;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Resolves the org context for each authenticated request from the {@code X-Org-Id} (and optional
|
||||
* {@code X-Org-Type}) headers.
|
||||
*
|
||||
* <p>When the header is present AND the authenticated user has an active membership for that org,
|
||||
* the {@link OrgContext} is stored in {@link OrgContextHolder} for downstream services. When the
|
||||
* header is absent or the membership is invalid, no context is set — the request proceeds normally
|
||||
* (some endpoints don't need org scoping).
|
||||
*
|
||||
* <p>The thread-local is always cleared in {@code finally} to prevent leakage across pooled threads.
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class OrgContextResolver extends OncePerRequestFilter {
|
||||
|
||||
private final MembershipService membershipService;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||
FilterChain filterChain) throws ServletException, IOException {
|
||||
try {
|
||||
resolveOrgContext(request);
|
||||
filterChain.doFilter(request, response);
|
||||
} finally {
|
||||
OrgContextHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void resolveOrgContext(HttpServletRequest request) {
|
||||
String orgIdHeader = request.getHeader("X-Org-Id");
|
||||
if (orgIdHeader == null || orgIdHeader.isBlank()) {
|
||||
return; // No org selected — fine, not all requests need org context
|
||||
}
|
||||
|
||||
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||
if (auth == null || auth.getName() == null) {
|
||||
return; // Not authenticated — JwtAuthenticationFilter hasn't run or failed
|
||||
}
|
||||
|
||||
UUID userId;
|
||||
try {
|
||||
userId = UUID.fromString(auth.getName());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Could not parse userId from principal: {}", auth.getName());
|
||||
return;
|
||||
}
|
||||
|
||||
UUID orgId;
|
||||
try {
|
||||
orgId = UUID.fromString(orgIdHeader);
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Invalid X-Org-Id header format: {}", orgIdHeader);
|
||||
return;
|
||||
}
|
||||
|
||||
// Determine org type: from header or by searching user's memberships
|
||||
String orgTypeHeader = request.getHeader("X-Org-Type");
|
||||
final OrgType resolvedOrgType;
|
||||
if (orgTypeHeader != null && !orgTypeHeader.isBlank()) {
|
||||
try {
|
||||
resolvedOrgType = OrgType.valueOf(orgTypeHeader.toUpperCase());
|
||||
} catch (IllegalArgumentException e) {
|
||||
log.warn("Unknown X-Org-Type header value: {}", orgTypeHeader);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
resolvedOrgType = null;
|
||||
}
|
||||
|
||||
// Validate membership
|
||||
if (resolvedOrgType != null) {
|
||||
membershipService.resolve(userId, resolvedOrgType, orgId)
|
||||
.ifPresent(m -> OrgContextHolder.set(new OrgContext(resolvedOrgType, orgId)));
|
||||
} else {
|
||||
// No org type specified — find by orgId across user's active memberships
|
||||
membershipService.activeForUser(userId).stream()
|
||||
.filter(m -> m.getOrgId().equals(orgId))
|
||||
.findFirst()
|
||||
.ifPresent(m -> OrgContextHolder.set(new OrgContext(m.getOrgType(), orgId)));
|
||||
}
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.platesoft.auth.service;
|
||||
|
||||
import de.platesoft.auth.entity.*;
|
||||
import de.platesoft.auth.repository.AccessRequestRepository;
|
||||
import de.platesoft.auth.repository.UserRepository;
|
||||
import de.platesoft.auth.spi.AccessRequestMailer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Access request lifecycle service.
|
||||
*
|
||||
* <p>Manages self-service access requests: a user requests access to an org,
|
||||
* admins review, approve (→ membership granted) or deny.
|
||||
*
|
||||
* <p><b>Rate limiting (§10 threat model):</b> Max 3 pending requests per user per day
|
||||
* to prevent access-request DoS.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AccessRequestService {
|
||||
|
||||
private static final long MAX_PENDING_PER_USER = 3;
|
||||
|
||||
private final AccessRequestRepository requestRepository;
|
||||
private final MembershipService membershipService;
|
||||
private final AccessRequestMailer accessRequestMailer;
|
||||
private final UserRepository userRepository;
|
||||
|
||||
/**
|
||||
* Submit a new access request.
|
||||
*
|
||||
* @throws IllegalStateException if the user already has MAX_PENDING_PER_USER pending requests.
|
||||
*/
|
||||
@Transactional
|
||||
public AccessRequest submitRequest(User requester, OrgType orgType, UUID orgId,
|
||||
MembershipRole requestedRole, String justification) {
|
||||
long pendingCount = requestRepository.countByRequesterIdAndStatus(
|
||||
requester.getId(), AccessRequestStatus.PENDING);
|
||||
if (pendingCount >= MAX_PENDING_PER_USER) {
|
||||
throw new IllegalStateException(
|
||||
"Too many pending access requests (max " + MAX_PENDING_PER_USER + ")");
|
||||
}
|
||||
|
||||
AccessRequest request = AccessRequest.builder()
|
||||
.requester(requester)
|
||||
.orgType(orgType)
|
||||
.orgId(orgId)
|
||||
.requestedRole(requestedRole)
|
||||
.justification(justification)
|
||||
.status(AccessRequestStatus.PENDING)
|
||||
.build();
|
||||
request = requestRepository.save(request);
|
||||
|
||||
accessRequestMailer.notifyAdmins(request);
|
||||
log.info("Access request submitted: requester={} org={}:{} role={}",
|
||||
requester.getId(), orgType, orgId, requestedRole);
|
||||
return request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Approve a pending access request — grants the requested membership.
|
||||
*/
|
||||
@Transactional
|
||||
public Membership approveRequest(UUID requestId, UUID reviewerId, String decisionReason) {
|
||||
AccessRequest request = requestRepository.findById(requestId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Access request not found: " + requestId));
|
||||
if (request.getStatus() != AccessRequestStatus.PENDING) {
|
||||
throw new IllegalStateException("Request is not pending (status=" + request.getStatus() + ")");
|
||||
}
|
||||
|
||||
User requester = request.getRequester();
|
||||
Membership membership = membershipService.grant(
|
||||
requester, request.getOrgType(), request.getOrgId(),
|
||||
request.getRequestedRole(), reviewerId,
|
||||
"Access request approved: " + decisionReason);
|
||||
|
||||
request.setStatus(AccessRequestStatus.APPROVED);
|
||||
request.setReviewerId(reviewerId);
|
||||
request.setDecisionReason(decisionReason);
|
||||
request.setDecidedAt(Instant.now());
|
||||
|
||||
accessRequestMailer.notifyRequester(request);
|
||||
log.info("Access request approved: id={} requester={} by={}", requestId, requester.getId(), reviewerId);
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deny a pending access request.
|
||||
*/
|
||||
@Transactional
|
||||
public void denyRequest(UUID requestId, UUID reviewerId, String decisionReason) {
|
||||
AccessRequest request = requestRepository.findById(requestId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Access request not found: " + requestId));
|
||||
if (request.getStatus() != AccessRequestStatus.PENDING) {
|
||||
throw new IllegalStateException("Request is not pending (status=" + request.getStatus() + ")");
|
||||
}
|
||||
|
||||
request.setStatus(AccessRequestStatus.DENIED);
|
||||
request.setReviewerId(reviewerId);
|
||||
request.setDecisionReason(decisionReason);
|
||||
request.setDecidedAt(Instant.now());
|
||||
|
||||
accessRequestMailer.notifyRequester(request);
|
||||
log.info("Access request denied: id={} requester={} by={}", requestId, request.getRequester().getId(), reviewerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* List pending access requests for an org.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<AccessRequest> pendingForOrg(OrgType orgType, UUID orgId) {
|
||||
return requestRepository.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, AccessRequestStatus.PENDING);
|
||||
}
|
||||
|
||||
/**
|
||||
* List a user's access requests.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<AccessRequest> forUser(UUID userId) {
|
||||
return requestRepository.findByRequesterIdAndStatus(userId, AccessRequestStatus.PENDING);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package de.platesoft.auth.service;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
import de.platesoft.auth.PlateAuthProperties;
|
||||
import de.platesoft.auth.dto.TokenResponse;
|
||||
import de.platesoft.auth.entity.Membership;
|
||||
import de.platesoft.auth.entity.Role;
|
||||
import de.platesoft.auth.entity.User;
|
||||
import de.platesoft.auth.repository.UserRepository;
|
||||
import io.jsonwebtoken.Claims;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* Password-based authentication service.
|
||||
*
|
||||
* <p>Handles login (credential verification), registration (if enabled), refresh-token rotation,
|
||||
* and the "current user" lookup for {@code GET /api/auth/me}.
|
||||
*
|
||||
* <p><b>Security note (§9.6 of Sprint-0-Plan).</b> Failed login returns a generic
|
||||
* "invalid credentials" message — never reveals whether the email exists or not.
|
||||
* All login attempts (success + failure) are recorded via {@link LoginEventService}.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AuthService {
|
||||
|
||||
private final UserRepository userRepository;
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
private final JwtService jwtService;
|
||||
private final LoginEventService loginEventService;
|
||||
private final MembershipService membershipService;
|
||||
private final PlateAuthProperties props;
|
||||
|
||||
/**
|
||||
* Authenticate with email + password.
|
||||
*
|
||||
* @throws BadCredentialsException if the email doesn't exist or the password is wrong.
|
||||
* The exception message is intentionally generic — no "user exists" leak.
|
||||
*/
|
||||
@Transactional
|
||||
public TokenResponse login(String email, String password, String ipAddress, String userAgent) {
|
||||
User user = userRepository.findByEmail(email).orElse(null);
|
||||
|
||||
if (user == null || user.getPasswordHash() == null
|
||||
|| !passwordEncoder.matches(password, user.getPasswordHash())) {
|
||||
loginEventService.recordFailure(email, "password", "BAD_CREDENTIALS", ipAddress, userAgent);
|
||||
throw new BadCredentialsException("Invalid email or password");
|
||||
}
|
||||
|
||||
if (!user.isActive()) {
|
||||
loginEventService.recordFailure(email, "password", "LOCKED", ipAddress, userAgent);
|
||||
throw new BadCredentialsException("Account is deactivated");
|
||||
}
|
||||
|
||||
user.setLastProvider("password");
|
||||
user.setLastLogin(OffsetDateTime.now());
|
||||
loginEventService.recordSuccess(user, "password", ipAddress, userAgent);
|
||||
return jwtService.issueTokensFor(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a new user with email + password.
|
||||
*
|
||||
* @throws IllegalStateException if registration is disabled or the email is already in use.
|
||||
*/
|
||||
@Transactional
|
||||
public TokenResponse register(String email, String password, String firstName, String lastName,
|
||||
String ipAddress, String userAgent) {
|
||||
if (!props.getRegistration().isEnabled()) {
|
||||
throw new IllegalStateException("Registration is disabled");
|
||||
}
|
||||
if (userRepository.existsByEmail(email)) {
|
||||
throw new IllegalStateException("Email already registered");
|
||||
}
|
||||
|
||||
User user = User.builder()
|
||||
.email(email)
|
||||
.passwordHash(passwordEncoder.encode(password))
|
||||
.firstName(firstName)
|
||||
.lastName(lastName)
|
||||
.role(Role.ROLE_USER)
|
||||
.active(true)
|
||||
.lastProvider("password")
|
||||
.build();
|
||||
user = userRepository.save(user);
|
||||
|
||||
loginEventService.recordSuccess(user, "password", ipAddress, userAgent);
|
||||
return jwtService.issueTokensFor(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refresh an access token using a valid refresh token.
|
||||
*
|
||||
* @throws BadCredentialsException if the refresh token is invalid, expired, or not a refresh-type token.
|
||||
*/
|
||||
public TokenResponse refresh(String refreshToken) {
|
||||
if (!jwtService.isTokenValid(refreshToken)) {
|
||||
throw new BadCredentialsException("Invalid refresh token");
|
||||
}
|
||||
Claims claims = jwtService.extractClaims(refreshToken);
|
||||
if (!"refresh".equals(claims.get("type", String.class))) {
|
||||
throw new BadCredentialsException("Token is not a refresh token");
|
||||
}
|
||||
|
||||
UUID userId = UUID.fromString(claims.getSubject());
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new BadCredentialsException("User not found for refresh token"));
|
||||
if (!user.isActive()) {
|
||||
throw new BadCredentialsException("Account is deactivated");
|
||||
}
|
||||
return jwtService.issueTokensFor(user);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current user + active memberships for {@code GET /api/auth/me}.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public CurrentUserInfo getCurrentUser(UUID userId) {
|
||||
User user = userRepository.findById(userId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||
List<Membership> memberships = membershipService.activeForUser(userId);
|
||||
return new CurrentUserInfo(user, memberships);
|
||||
}
|
||||
|
||||
/**
|
||||
* Record for {@link #getCurrentUser} — the user entity + their active memberships.
|
||||
*/
|
||||
public record CurrentUserInfo(User user, List<Membership> memberships) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Thrown when login or refresh fails. Maps to HTTP 401.
|
||||
*/
|
||||
public static class BadCredentialsException extends RuntimeException {
|
||||
public BadCredentialsException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
package de.platesoft.auth.service;
|
||||
|
||||
import de.platesoft.auth.entity.*;
|
||||
import de.platesoft.auth.repository.InvitationRepository;
|
||||
import de.platesoft.auth.spi.InvitationMailer;
|
||||
import de.platesoft.auth.spi.OrgDisplayNameResolver;
|
||||
import de.platesoft.auth.spi.OrgValidator;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Base64;
|
||||
import java.util.HexFormat;
|
||||
import java.util.List;
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* Invitation lifecycle service.
|
||||
*
|
||||
* <p>Manages the full invitation workflow: create (generate token, store hash, mail),
|
||||
* accept (validate token, grant membership, mark accepted), revoke, and list.
|
||||
*
|
||||
* <p><b>Security:</b> Invitation tokens are 64-char URL-safe random values. Only the
|
||||
* SHA-256 hash is stored in the database — the plaintext token is returned to the caller
|
||||
* exactly once and sent to the invitee via the {@link InvitationMailer} SPI.
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class InvitationService {
|
||||
|
||||
private static final Duration DEFAULT_EXPIRATION = Duration.ofDays(7);
|
||||
private static final int TOKEN_LENGTH = 48; // bytes → 64-char Base64 string
|
||||
|
||||
private final InvitationRepository invitationRepository;
|
||||
private final MembershipService membershipService;
|
||||
private final InvitationMailer invitationMailer;
|
||||
private final OrgDisplayNameResolver orgDisplayNameResolver;
|
||||
|
||||
/**
|
||||
* Create a new invitation.
|
||||
*
|
||||
* @param email the invitee's email
|
||||
* @param orgType the org type (COMPANY, WORKSPACE, etc.)
|
||||
* @param orgId the org UUID
|
||||
* @param role the role to grant on acceptance
|
||||
* @param createdBy the inviting user's UUID
|
||||
* @return the created invitation with the plaintext token (returned once — not stored)
|
||||
*/
|
||||
@Transactional
|
||||
public Invitation createInvitation(String email, OrgType orgType, UUID orgId,
|
||||
MembershipRole role, UUID createdBy) {
|
||||
// Generate token
|
||||
byte[] tokenBytes = new byte[TOKEN_LENGTH];
|
||||
new SecureRandom().nextBytes(tokenBytes);
|
||||
String plaintextToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
|
||||
String tokenHash = sha256Hex(plaintextToken);
|
||||
|
||||
Invitation invitation = Invitation.builder()
|
||||
.token(tokenHash)
|
||||
.email(email)
|
||||
.orgType(orgType)
|
||||
.orgId(orgId)
|
||||
.role(role)
|
||||
.status(InvitationStatus.PENDING)
|
||||
.createdBy(createdBy)
|
||||
.createdAt(Instant.now())
|
||||
.expiresAt(Instant.now().plus(DEFAULT_EXPIRATION))
|
||||
.build();
|
||||
invitation = invitationRepository.save(invitation);
|
||||
|
||||
// Mail the invitee with the plaintext token URL
|
||||
String orgName = orgDisplayNameResolver.displayName(orgType, orgId);
|
||||
invitationMailer.sendInvitation(invitation, plaintextToken);
|
||||
log.info("Invitation created: email={} org={}:{} role={} by={}",
|
||||
email, orgType, orgId, role, createdBy);
|
||||
|
||||
// Return a copy with the plaintext token so the caller can see it once
|
||||
var withToken = Invitation.builder()
|
||||
.id(invitation.getId())
|
||||
.token(plaintextToken) // plaintext, not the hash
|
||||
.email(invitation.getEmail())
|
||||
.orgType(invitation.getOrgType())
|
||||
.orgId(invitation.getOrgId())
|
||||
.role(invitation.getRole())
|
||||
.status(invitation.getStatus())
|
||||
.createdBy(invitation.getCreatedBy())
|
||||
.createdAt(invitation.getCreatedAt())
|
||||
.expiresAt(invitation.getExpiresAt())
|
||||
.build();
|
||||
return withToken;
|
||||
}
|
||||
|
||||
/**
|
||||
* Accept an invitation using the plaintext token.
|
||||
*
|
||||
* @param plaintextToken the token from the invitation email
|
||||
* @param user the accepting user
|
||||
* @throws IllegalArgumentException if the token is invalid, expired, already accepted, or revoked
|
||||
*/
|
||||
@Transactional
|
||||
public Membership acceptInvitation(String plaintextToken, User user) {
|
||||
String tokenHash = sha256Hex(plaintextToken);
|
||||
Invitation invitation = invitationRepository.findByToken(tokenHash)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invalid invitation token"));
|
||||
|
||||
if (invitation.getStatus() == InvitationStatus.ACCEPTED) {
|
||||
throw new IllegalStateException("Invitation already accepted");
|
||||
}
|
||||
if (invitation.getStatus() == InvitationStatus.REVOKED) {
|
||||
throw new IllegalStateException("Invitation has been revoked");
|
||||
}
|
||||
if (invitation.getExpiresAt().isBefore(Instant.now())) {
|
||||
invitation.setStatus(InvitationStatus.EXPIRED);
|
||||
throw new IllegalStateException("Invitation has expired");
|
||||
}
|
||||
|
||||
// Grant membership
|
||||
Membership membership = membershipService.grant(
|
||||
user, invitation.getOrgType(), invitation.getOrgId(),
|
||||
invitation.getRole(), invitation.getCreatedBy(),
|
||||
"Invitation accepted");
|
||||
|
||||
// Mark invitation as accepted
|
||||
invitation.setStatus(InvitationStatus.ACCEPTED);
|
||||
invitation.setAcceptedAt(Instant.now());
|
||||
invitation.setAcceptedBy(user.getId());
|
||||
|
||||
log.info("Invitation accepted: email={} org={}:{} user={}",
|
||||
invitation.getEmail(), invitation.getOrgType(), invitation.getOrgId(), user.getId());
|
||||
return membership;
|
||||
}
|
||||
|
||||
/**
|
||||
* Revoke a pending invitation.
|
||||
*/
|
||||
@Transactional
|
||||
public void revokeInvitation(UUID invitationId, UUID revokedBy) {
|
||||
Invitation invitation = invitationRepository.findById(invitationId)
|
||||
.orElseThrow(() -> new IllegalArgumentException("Invitation not found: " + invitationId));
|
||||
if (invitation.getStatus() != InvitationStatus.PENDING) {
|
||||
throw new IllegalStateException("Can only revoke pending invitations");
|
||||
}
|
||||
invitation.setStatus(InvitationStatus.REVOKED);
|
||||
invitation.setRevokedAt(Instant.now());
|
||||
invitation.setRevokedBy(revokedBy);
|
||||
log.info("Invitation revoked: id={} by={}", invitationId, revokedBy);
|
||||
}
|
||||
|
||||
/**
|
||||
* List pending invitations for an org.
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public List<Invitation> pendingForOrg(OrgType orgType, UUID orgId) {
|
||||
return invitationRepository.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, InvitationStatus.PENDING);
|
||||
}
|
||||
|
||||
private String sha256Hex(String input) {
|
||||
try {
|
||||
MessageDigest md = MessageDigest.getInstance("SHA-256");
|
||||
return HexFormat.of().formatHex(md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
|
||||
} catch (Exception e) {
|
||||
throw new IllegalStateException("SHA-256 hash failed", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,8 +6,25 @@ 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.
|
||||
*
|
||||
* <p><b>Transaction contract (Review-v2 W-A).</b> Both methods are invoked <em>inside</em> the
|
||||
* {@code @Transactional} boundary of {@code ExchangeService.verifyAndExchange} — i.e. within the same
|
||||
* transaction that creates/updates the user and records the login event. Two consequences consumers
|
||||
* must respect:
|
||||
* <ul>
|
||||
* <li><b>Keep it fast and idempotent.</b> A slow hook blocks the login response; a failing hook
|
||||
* rolls back the entire sign-in (user creation + login event). Do heavy/external work
|
||||
* asynchronously, or move it to a post-commit listener, not inline here.</li>
|
||||
* <li><b>Idempotency required.</b> The same identity may trigger {@code onFirstSignIn} more than
|
||||
* once under retries/races; implementations must be safe to call repeatedly.</li>
|
||||
* </ul>
|
||||
* Future v0.2 may move these calls to a post-commit phase; until then assume "in-transaction".
|
||||
*/
|
||||
public interface OnboardingHook {
|
||||
/**
|
||||
* Called the first time a user authenticates via a provider (no prior {@code user_identity}).
|
||||
* Runs inside the exchange transaction — see class-level contract.
|
||||
*/
|
||||
void onFirstSignIn(User user, LoginProvider provider);
|
||||
|
||||
default void onSubsequentSignIn(User user, LoginProvider provider) {
|
||||
|
||||
Reference in New Issue
Block a user