diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java index fd92ee6..ea70880 100644 --- a/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/PlateAuthAutoConfiguration.java @@ -3,8 +3,15 @@ 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; @@ -46,7 +53,14 @@ import org.springframework.scheduling.annotation.EnableAsync; JwtService.class, LoginEventService.class, MembershipService.class, - OAuthController.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") diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java b/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java index 030dde1..899587f 100644 --- a/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/config/SecurityConfig.java @@ -2,7 +2,9 @@ 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; @@ -57,6 +59,11 @@ public class SecurityConfig { return new JwtAuthenticationFilter(jwtService); } + @Bean + public OrgContextResolver orgContextResolver(MembershipService membershipService) { + return new OrgContextResolver(membershipService); + } + /** * plate-auth's security chain, scoped to {@link #PLATE_AUTH_PATHS} only. * @@ -69,6 +76,7 @@ public class SecurityConfig { public SecurityFilterChain plateAuthSecurityFilterChain( HttpSecurity http, JwtAuthenticationFilter jwtFilter, + OrgContextResolver orgContextResolver, PlateAuthProperties props) throws Exception { http .securityMatcher(PLATE_AUTH_PATHS) @@ -82,13 +90,13 @@ public class SecurityConfig { "/api/auth/register", "/api/auth/refresh", "/api/auth/config", - "/api/access-requests", - "/api/access-requests/**" + "/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(); } diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AccessRequestController.java b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AccessRequestController.java new file mode 100644 index 0000000..a7b780d --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AccessRequestController.java @@ -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. + * + *

Endpoints under {@code /api/access-requests/**}: + *

+ */ +@RestController +@RequestMapping("/api/access-requests") +@RequiredArgsConstructor +public class AccessRequestController { + + private final AccessRequestService accessRequestService; + private final UserRepository userRepository; + + @PostMapping + public ResponseEntity 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> 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> 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> 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()); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AdminAuditController.java b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AdminAuditController.java new file mode 100644 index 0000000..969f2d0 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AdminAuditController.java @@ -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. + * + *

Endpoints under {@code /api/admin/**} — enforced by + * {@code .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")} in {@link + * de.platesoft.auth.config.SecurityConfig SecurityConfig}. + * + *

    + *
  • {@code GET /api/admin/login-events} — paginated login event audit log
  • + *
+ * + *

Note: 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> 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)); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AuthController.java b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AuthController.java new file mode 100644 index 0000000..dd3366a --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/AuthController.java @@ -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. + * + *

Endpoints mounted under {@code /api/auth/**} (scoped by plate-auth's SecurityFilterChain): + *

    + *
  • {@code POST /api/auth/login} — password login (public)
  • + *
  • {@code POST /api/auth/register} — password registration (public, if enabled)
  • + *
  • {@code POST /api/auth/refresh} — refresh token rotation (public)
  • + *
  • {@code GET /api/auth/me} — current user + memberships (authenticated)
  • + *
  • {@code GET /api/auth/config} — provider list + registration flag (public)
  • + *
+ */ +@Slf4j +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final AuthService authService; + private final PlateAuthProperties props; + + @PostMapping("/login") + public ResponseEntity 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 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 refresh(@Valid @RequestBody RefreshRequest req) { + TokenResponse tokens = authService.refresh(req.refreshToken()); + return ResponseEntity.ok(tokens); + } + + @GetMapping("/me") + public ResponseEntity me() { + UUID userId = getCurrentUserId(); + var info = authService.getCurrentUser(userId); + return ResponseEntity.ok(UserResponse.from(info.user(), info.memberships())); + } + + @GetMapping("/config") + public ResponseEntity config() { + List 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 handleBadCredentials(AuthService.BadCredentialsException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage()); + } + + @ExceptionHandler(IllegalStateException.class) + public ResponseEntity 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()); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/controller/InvitationController.java b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/InvitationController.java new file mode 100644 index 0000000..7eb1df4 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/controller/InvitationController.java @@ -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. + * + *

Endpoints under {@code /api/invitations/**}: + *

    + *
  • {@code POST /api/invitations} — create invitation (authenticated)
  • + *
  • {@code POST /api/invitations/accept} — accept invitation (authenticated, with token)
  • + *
  • {@code DELETE /api/invitations/{id}} — revoke invitation (authenticated)
  • + *
  • {@code GET /api/invitations} — list pending invitations (authenticated)
  • + *
+ */ +@RestController +@RequestMapping("/api/invitations") +@RequiredArgsConstructor +public class InvitationController { + + private final InvitationService invitationService; + private final UserRepository userRepository; + + @PostMapping + public ResponseEntity 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> accept(@RequestBody Map 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 revoke(@PathVariable UUID id) { + UUID revokedBy = getCurrentUserId(); + invitationService.revokeInvitation(id, revokedBy); + return ResponseEntity.noContent().build(); + } + + @GetMapping + public ResponseEntity> 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()); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/AuthConfigResponse.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/AuthConfigResponse.java new file mode 100644 index 0000000..d36ae63 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/AuthConfigResponse.java @@ -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 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); + } + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/CreateAccessRequestRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/CreateAccessRequestRequest.java new file mode 100644 index 0000000..e36c9eb --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/CreateAccessRequestRequest.java @@ -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 +) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/CreateInvitationRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/CreateInvitationRequest.java new file mode 100644 index 0000000..5eb98e2 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/CreateInvitationRequest.java @@ -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 +) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/LoginRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/LoginRequest.java new file mode 100644 index 0000000..60e658c --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/LoginRequest.java @@ -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 +) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/RefreshRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/RefreshRequest.java new file mode 100644 index 0000000..dc5db07 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/RefreshRequest.java @@ -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 +) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/RegisterRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/RegisterRequest.java new file mode 100644 index 0000000..a32971f --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/RegisterRequest.java @@ -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 +) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/ReviewAccessRequestRequest.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/ReviewAccessRequestRequest.java new file mode 100644 index 0000000..221b217 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/ReviewAccessRequestRequest.java @@ -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 +) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/dto/UserResponse.java b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/UserResponse.java new file mode 100644 index 0000000..b330bd0 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/dto/UserResponse.java @@ -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 memberships +) { + public static UserResponse from(User user, List 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() + ); + } + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContext.java b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContext.java new file mode 100644 index 0000000..c7c0623 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContext.java @@ -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. + * + *

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) { +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContextHolder.java b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContextHolder.java new file mode 100644 index 0000000..b543906 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContextHolder.java @@ -0,0 +1,28 @@ +package de.platesoft.auth.filter; + +/** + * Thread-local holder for the {@link OrgContext} resolved on the current request. + * + *

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 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(); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContextResolver.java b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContextResolver.java new file mode 100644 index 0000000..eab8bd8 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/filter/OrgContextResolver.java @@ -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. + * + *

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). + * + *

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))); + } + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/AccessRequestService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/AccessRequestService.java new file mode 100644 index 0000000..e2fd869 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/AccessRequestService.java @@ -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. + * + *

Manages self-service access requests: a user requests access to an org, + * admins review, approve (→ membership granted) or deny. + * + *

Rate limiting (§10 threat model): 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 pendingForOrg(OrgType orgType, UUID orgId) { + return requestRepository.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, AccessRequestStatus.PENDING); + } + + /** + * List a user's access requests. + */ + @Transactional(readOnly = true) + public List forUser(UUID userId) { + return requestRepository.findByRequesterIdAndStatus(userId, AccessRequestStatus.PENDING); + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/AuthService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/AuthService.java new file mode 100644 index 0000000..bad5169 --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/AuthService.java @@ -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. + * + *

Handles login (credential verification), registration (if enabled), refresh-token rotation, + * and the "current user" lookup for {@code GET /api/auth/me}. + * + *

Security note (§9.6 of Sprint-0-Plan). 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 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 memberships) { + } + + /** + * Thrown when login or refresh fails. Maps to HTTP 401. + */ + public static class BadCredentialsException extends RuntimeException { + public BadCredentialsException(String message) { + super(message); + } + } +} diff --git a/plate-auth-starter/src/main/java/de/platesoft/auth/service/InvitationService.java b/plate-auth-starter/src/main/java/de/platesoft/auth/service/InvitationService.java new file mode 100644 index 0000000..9f5b6ce --- /dev/null +++ b/plate-auth-starter/src/main/java/de/platesoft/auth/service/InvitationService.java @@ -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. + * + *

Manages the full invitation workflow: create (generate token, store hash, mail), + * accept (validate token, grant membership, mark accepted), revoke, and list. + * + *

Security: 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 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); + } + } +}