diff --git a/articles/LoginPipeline.md b/articles/LoginPipeline.md new file mode 100644 index 000000000..898bf6194 --- /dev/null +++ b/articles/LoginPipeline.md @@ -0,0 +1,438 @@ +# Unified Login Pipeline + +## Overview + +WebAPI implements a **unified authentication pipeline** that consolidates all authentication types (Database, LDAP, Windows/Kerberos, and OIDC) through a single entry point. This design ensures consistent user lifecycle management, role synchronization, and JWT token generation across all authentication methods. + +**Key Achievement:** All authentication types converge through `LoginService.onSuccess(AuthenticatedLogin)`, eliminating code duplication and providing a single source of truth for login orchestration. + +## Architecture + +### The Login Pipeline Flow + +``` +┌─────────────────────────────────────────────────────────────────┐ +│ HTTP Authentication Request │ +└────────────┬────────────────────────────────────────────────────┘ + │ + ├──────────────────┬──────────────────┬──────────────┐ + │ │ │ │ + ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼─────┐ ┌──────▼──────┐ + │ Database │ │ LDAP │ │ Windows │ │ OIDC │ + │ Auth │ │ Auth │ │ Auth │ │ Auth │ + │ (POST/GET) │ │ (GET) │ │ (GET) │ │ (Redirect/ │ + │ │ │ │ │ │ │ Bearer) │ + └──────┬─────┘ └──────┬─────┘ └──────┬─────┘ └──────┬──────┘ + │ │ │ │ + │ ▼ ▼ ▼ + │ ┌────────────────────────────────┐ + │ │ Group-to-Role Mappers │ + │ │ (Phase 1: Return Empty Set) │ + │ │ (Phase 2: Actual Mapping) │ + │ └────────────────────────────────┘ + │ │ + └────────┬───────────┴────────┬────────────┘ + │ │ + ▼ ▼ + ┌────────────────────────────────────────┐ + │ Build AuthenticatedLogin │ + │ ├─ login (normalized) │ + │ ├─ name (display name) │ + │ ├─ origin (UserOrigin enum) │ + │ ├─ roles (Set) │ + │ └─ originAuthentication (for debug) │ + └────────────────┬─────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────┐ + │ LoginService.onSuccess() │ + │ (Single Orchestration Point) │ + │ │ + │ 1. Lowercase login name │ + │ 2. Ensure user exists in DB │ + │ 3. Sync roles by origin │ + │ 4. Create session │ + │ 5. Generate JWT │ + │ 6. Return Result │ + └────────────────┬─────────────────────┘ + │ + ▼ + ┌────────────────────────────────────────┐ + │ LoginService.Result │ + │ ├─ login │ + │ ├─ jwt (signed token) │ + │ ├─ roles (final DB roles) │ + │ └─ message │ + └────────────────────────────────────────┘ +``` + +### Core Components + +#### 1. **AuthenticatedLogin** — The Bridge Pattern + +The `AuthenticatedLogin` class is the **normalization bridge** between disparate authentication sources and the unified login orchestration logic. + +**Purpose:** Convert auth-specific data (Spring Authentication, JWT claims, LDAP attributes, etc.) into a standardized format. + +**Key Fields:** +```java +public class AuthenticatedLogin { + private String login; // Normalized login (lowercase) + private String name; // Display name + private UserOrigin origin; // Where the user came from + private Set roles; // Pre-extracted WebAPI role names + private Authentication originAuthentication; // Original Spring Auth (for debugging) + private Map attributes; // Optional auth-specific data +} +``` + +**Why a Bridge?** +- Different auth methods provide user identity in different ways: + - Database: direct username/password + - LDAP: DN + group memberships + attributes + - Windows: Kerberos SPN + group SIDs + - OIDC: JWT claims (subject, name, groups/roles) +- Each auth handler extracts what it needs, builds an `AuthenticatedLogin`, and calls `LoginService.onSuccess()` +- LoginService doesn't need to know authentication source details + +**Immutability Design:** +- `roles` is an immutable `Set` to prevent accidental modification +- Built via fluent builder pattern with validation +- Ensures consistency across all auth paths + +#### 2. **LoginService** — Orchestration Hub + +The `LoginService.onSuccess(AuthenticatedLogin)` method is the **single entry point** for ALL authentication types. It handles the complete login lifecycle: + +```java +@Transactional +public Result onSuccess(AuthenticatedLogin authenticatedLogin) { + // 1. Normalize login name + String login = authenticatedLogin.getLogin().toLowerCase(); + + // 2. Ensure user exists (create or update) + authorizationService.ensureUserExists( + login, + authenticatedLogin.getName(), + authenticatedLogin.getOrigin(), + defaultRoles + ); + + // 3. Sync roles (origin-aware) + syncRoles(login, authenticatedLogin.getOrigin(), authenticatedLogin.getRoles()); + + // 4. Create session + UUID sessionId = sessionService.createSession(login); + + // 5. Generate JWT + String jwt = jwtService.generateToken(login, sessionId.toString(), expiresAt); + + // 6. Fetch and return final roles + String[] roles = authorizationService.getUserRoles(login) + .stream() + .map(Role::name) + .toArray(String[]::new); + + return new Result(login, jwt, roles, "Login successful"); +} +``` + +**Key Design Decision: syncRoles() is Origin-Aware** + +The `syncRoles()` method **only modifies roles assigned by the current authentication origin:** + +```java +private void syncRoles(String login, UserOrigin origin, Set targetRoles) { + // Get current roles assigned by THIS origin + List currentOriginRoles = authorizationService.getRolesByOrigin(login, origin); + + // Add new roles from this origin + for (String roleName : targetRoles) { + if (!currentOriginRoles.contains(roleName)) { + authorizationService.addUserToRole(roleName, login, origin); + } + } + + // Remove roles no longer in target (only for this origin) + for (String roleName : currentOriginRoles) { + if (!targetRoles.contains(roleName)) { + authorizationService.removeUserFromRole(roleName, login, origin); + } + } +} +``` + +**Why Origin-Aware Sync?** + +Imagine a user authenticated via both LDAP and OIDC: +- LDAP login assigns roles: `["admin", "analyst"]` +- User also has OIDC token with roles: `["analyst"]` +- If OIDC sync removed unmatched roles, it would accidentally remove the LDAP-assigned `admin` role +- Origin-aware sync prevents this: OIDC only removes OIDC-origin roles + +#### 3. **Authentication Handlers** — Consistent Pattern + +Each authentication method follows the same pattern: + +``` +1. Authenticate the user (via Spring Security mechanisms) +2. Extract groups/roles from authentication (mappers return empty in Phase 1) +3. Build AuthenticatedLogin with normalized data +4. Call loginService.onSuccess(authenticatedLogin) +5. Return Result (JWT + roles) to client +``` + +**Database Handler Example** (`LoginController.Database`): +```java +@GetMapping("/db") +public Result getDbLogin(Authentication auth) { + AuthenticatedLogin login = AuthenticatedLogin.builder() + .login(auth.getName()) + .name(auth.getName()) + .origin(UserOrigin.DATABASE) + .roles(Collections.emptySet()) // DB auth has no groups + .originAuthentication(auth) + .build(); + + return loginService.onSuccess(login); +} +``` + +**LDAP Handler Example** (`LoginController.Ldap`): +```java +@GetMapping("/ldap") +public Result getLdapLogin(Authentication auth) { + // Groups come from Spring's LDAP authorities + Set mappedRoles = ldapGroupToRoleMapper.mapGroupsToRoles( + auth.getAuthorities(), + LdapProviderType.LDAP + ); + + AuthenticatedLogin login = AuthenticatedLogin.builder() + .login(auth.getName()) + .name(auth.getName()) + .origin(UserOrigin.LDAP) + .roles(mappedRoles) // Phase 1: empty, Phase 2: actual groups + .originAuthentication(auth) + .build(); + + return loginService.onSuccess(login); +} +``` + +**OIDC Handler Example** (`OidcAuthConfig.handleSuccess`): +```java +public void handleSuccess(OAuth2User user, ...) { + OidcUser oidcUser = (OidcUser) user; + + // Extract roles from JWT claims + Set mappedRoles = oidcGroupToRoleMapper.extractAndMapRoles( + oidcUser.getClaims(), + rolesClaim, + rolesToUpperCase + ); + + AuthenticatedLogin login = AuthenticatedLogin.builder() + .login(oidcUser.getSubject()) + .name(oidcUser.getFullName()) + .origin(UserOrigin.OIDC) + .roles(mappedRoles) // Phase 1: empty, Phase 2: JWT claims + .originAuthentication(originalOAuth2Auth) + .build(); + + Result result = loginService.onSuccess(login); + redirectToUI(result.jwt); +} +``` + +## Design Principles + +### 1. **Single Responsibility** +- Each component has one reason to change: + - **Handlers:** How to authenticate this type + - **Mappers:** How to extract groups from this type + - **LoginService:** How to complete the login lifecycle + - **AuthorizationService:** How to manage users and roles + +### 2. **Separation of Concerns** +- Authentication ≠ Authorization + - Handlers focus on authentication (proving identity) + - LoginService + AuthorizationService focus on authorization (roles and permissions) +- Group mapping separated from login orchestration + - Mappers are pluggable (Phase 1: empty, Phase 2: real logic) + - Allows independent testing and evolution + +### 3. **No Premature Optimization** +- **Phase 1:** Mappers return empty sets, users get default roles only +- **Phase 2:** Implement group-to-role mapping when infrastructure is clear +- Avoids coupling to LDAP import job infrastructure prematurely + +### 4. **Transactional Consistency** +- `onSuccess()` is `@Transactional` — all DB operations atomic +- User creation, role sync, session creation all succeed or all rollback together +- Prevents partial login state + +### 5. **Extensibility** +- New auth types add handlers following the same pattern +- New group mapping providers add mappers following the same interface +- AuthenticatedLogin fields allow carrying arbitrary auth-specific data + +## How Each Authentication Type Flows + +### Database Authentication +1. User submits username/password +2. Spring's `DaoAuthenticationProvider` validates against DB +3. Handler builds `AuthenticatedLogin` with `origin=DATABASE` +4. No groups (RDBMS auth doesn't have group concept) +5. Roles come from default roles only + +### LDAP Authentication +1. User submits credentials +2. Spring's `LdapAuthenticationProvider` validates against LDAP server +3. **Phase 1:** `LdapGroupToRoleMapper` returns empty set +4. **Phase 2:** Mapper will query `sec_role_group` table for LDAP group→role mappings +5. Handler builds `AuthenticatedLogin` with `origin=LDAP` and mapped roles +6. `syncRoles()` adds/removes LDAP-origin roles + +### Windows/Kerberos Authentication +1. Browser sends Kerberos token via `Authorization: Negotiate` +2. Spring's `NegotiateSecurityFilter` (via Waffle) validates token +3. Spring extracts Windows group SIDs as authorities +4. **Phase 1:** `WindowsGroupToRoleMapper` returns empty set +5. **Phase 2:** Mapper will query ACTIVE_DIRECTORY provider for SID→role mappings +6. Handler builds `AuthenticatedLogin` with `origin=WINDOWS` and mapped roles + +### OIDC Authentication (Redirect Flow) +1. User clicks "Login with OIDC Provider" +2. Browser redirected to OIDC provider login page +3. OIDC provider redirects back with authorization code +4. Spring exchanges code for ID token + access token +5. Handler extracts claims from ID token +6. **Phase 1:** `OidcGroupToRoleMapper` returns empty set +7. **Phase 2:** Mapper will extract roles from configurable JWT claim paths +8. Handler builds `AuthenticatedLogin` with `origin=OIDC` and mapped roles +9. Redirects to UI with JWT in fragment + +### OIDC Authentication (Direct Bearer Token) +1. API client sends JWT in `Authorization: Bearer` header +2. Spring validates signature via `JwtDecoder` +3. Handler extracts claims directly from JWT +4. **Phase 1:** `OidcGroupToRoleMapper` returns empty set +5. **Phase 2:** Mapper extracts roles from token claims +6. Handler builds `AuthenticatedLogin` with `origin=OIDC` and mapped roles +7. Returns JSON with new JWT (refreshed expiration) + +## UserOrigin Tracking + +Each user's authentication history is tracked via `UserOrigin` enum: + +```java +enum UserOrigin { + SYSTEM, // Programmatically created + AD, // Active Directory (legacy) + LDAP, // LDAP authentication + WINDOWS, // Kerberos/SPNEGO + KERBEROS, // Alternative Kerberos tracking + GOOGLE, // OAuth2 Google + FACEBOOK, // OAuth2 Facebook + DATABASE, // Local database credentials + OIDC // Generic OIDC provider +} +``` + +**Purpose:** +- Tracks which authentication method created/updated the user +- Enables origin-aware role synchronization +- Supports audit trails and analytics + +**User Lifecycle Example:** +``` +1. User logs in via LDAP + → UserEntity created with origin=LDAP + → LDAP-origin roles assigned + +2. User logs in via OIDC + → UserEntity origin updated to reflect most recent auth? (TBD) + → OIDC-origin roles added alongside LDAP-origin roles + → Origin-aware sync ensures neither auth method removes other's roles + +3. Admin manually assigns role + → Role created with origin=SYSTEM + → Survives all future logins regardless of auth method +``` + +## Phase 1 vs Phase 2: Group Mapping Strategy + +### Why We Deferred Group Mapping + +The original design attempt to implement group-to-role mapping immediately ran into **fundamental infrastructure issues:** + +1. **LDAP/Windows Groups:** The `sec_role_group` table + `RoleGroupEntity` are tightly coupled to the LDAP import job + - Import job manages LDAP group discovery, caching, and lifecycle + - Using it for login-time group mapping requires careful synchronization + - Risk of stale mappings or unexpected interactions + +2. **OIDC Roles:** JWT claim structure varies by OIDC provider + - Some put roles in `realm_access.roles` (Keycloak) + - Others use `roles` directly + - Some use custom claim paths + - Configurable extraction logic needed, but interaction with existing role filtering unclear + +3. **Architectural Mismatch:** The provisioning module (import jobs, bulk operations) and authentication module (login handlers) serve different purposes + - Import jobs: Periodic bulk sync of users/groups from external source + - Login handlers: Individual user login, just-in-time provisioning + - Merging these concerns prematurely creates technical debt + +### Phase 1 Solution: Placeholders +All mappers return empty `Set`: +- Users are assigned **default roles only** at login +- No automatic group-based role assignment +- Allows unified pipeline to work immediately +- Baseline for testing and refinement + +### Phase 2 Implementation Plan + +**Prerequisites:** +1. Clarify relationship between import job and login-time group mapping +2. Design for LDAP group caching/freshness +3. Define OIDC role claim extraction strategy +4. Potentially create new entities/repositories for group mappings + +**Implementation:** +1. Implement `LdapGroupToRoleMapper.mapGroupsToRoles()` with real group lookups +2. Implement `WindowsGroupToRoleMapper.mapGroupsToRoles()` for AD integration +3. Implement `OidcGroupToRoleMapper.extractAndMapRoles()` with claim parsing +4. Add configuration for OIDC claim paths and role filtering +5. Add tests for each mapper against real external systems +6. Consider caching for performance + +## Extension Points + +### Adding a New Authentication Type + +1. Create handler class (e.g., `OAuthHandler`) +2. Implement authentication via Spring Security +3. Extract roles via a mapper if applicable +4. Build `AuthenticatedLogin` instance +5. Call `loginService.onSuccess(authenticatedLogin)` + +### Adding a New External System Integration + +1. Create a new `GroupToRoleMapper` implementation +2. Implement `mapGroupsToRoles()` to query your system's group structure +3. Inject into the authentication handler +4. Handler calls mapper, passes results to `AuthenticatedLogin` + +### Modifying Role Synchronization Logic + +1. Update `LoginService.syncRoles()` if you need different origin-awareness behavior +2. Or override in `AuthorizationService` if policy change needed +3. Remember: Changes here affect **all** authentication types + +## Testing Strategy + +- **Unit tests** for each mapper (Phase 2) +- **Integration tests** for each auth handler + LoginService +- **End-to-end tests** for complete login flows +- **Multi-auth tests:** Same user logging in via different methods + diff --git a/src/main/java/org/ohdsi/webapi/security/authc/AuthenticatedLogin.java b/src/main/java/org/ohdsi/webapi/security/authc/AuthenticatedLogin.java new file mode 100644 index 000000000..d12eea3f9 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/authc/AuthenticatedLogin.java @@ -0,0 +1,199 @@ +package org.ohdsi.webapi.security.authc; + +import org.springframework.security.core.Authentication; +import org.ohdsi.webapi.security.authc.UserOrigin; + +import java.util.*; + +/** + * Normalized authentication login object that bridges all authentication methods + * (Database, LDAP, Windows, OIDC) into a common structure for the login pipeline. + * + * This class is used by all authentication handlers to standardize the data passed + * to LoginService.onSuccess(), ensuring consistent user creation, role mapping, + * session establishment, and JWT generation across all authentication types. + */ +public class AuthenticatedLogin { + + private final String login; + private final String name; + private final UserOrigin origin; + private final Set roles; + private final Authentication originAuthentication; + private final Map attributes; + + private AuthenticatedLogin(Builder builder) { + this.login = Objects.requireNonNull(builder.login, "login cannot be null"); + this.name = Objects.requireNonNull(builder.name, "name cannot be null"); + if (this.login.isBlank()) { + throw new IllegalArgumentException("login cannot be blank"); + } + if (this.name.isBlank()) { + throw new IllegalArgumentException("name cannot be blank"); + } + this.origin = Objects.requireNonNull(builder.origin, "origin cannot be null"); + this.roles = builder.roles != null ? new HashSet<>(builder.roles) : new HashSet<>(); + this.originAuthentication = builder.originAuthentication; + this.attributes = builder.attributes != null ? new HashMap<>(builder.attributes) : new HashMap<>(); + } + + /** + * Gets the normalized login name (typically lowercase). + */ + public String getLogin() { + return login; + } + + /** + * Gets the display name for the user. + */ + public String getName() { + return name; + } + + /** + * Gets the origin/source of this authentication. + */ + public UserOrigin getOrigin() { + return origin; + } + + /** + * Gets the set of WebAPI role names that should be assigned to this user. + * These roles should already be filtered to only valid WebAPI roles + * and mapped from the authentication source (e.g., LDAP groups, OIDC claims). + */ + public Set getRoles() { + return Collections.unmodifiableSet(roles); + } + + /** + * Gets the original Spring Authentication object for debugging/auditing purposes. + * May be null if not provided by the authentication handler. + */ + public Authentication getOriginAuthentication() { + return originAuthentication; + } + + /** + * Gets optional auth-type-specific attributes. + * Can be used to pass additional data through the login pipeline. + */ + public Map getAttributes() { + return Collections.unmodifiableMap(attributes); + } + + /** + * Gets an attribute by key, or null if not present. + */ + public Object getAttribute(String key) { + return attributes.get(key); + } + + /** + * Creates a new builder for constructing AuthenticatedLogin instances. + */ + public static Builder builder() { + return new Builder(); + } + + /** + * Builder for constructing AuthenticatedLogin instances. + */ + public static class Builder { + private String login; + private String name; + private UserOrigin origin; + private Set roles; + private Authentication originAuthentication; + private Map attributes; + + /** + * Sets the login name. + */ + public Builder login(String login) { + this.login = login; + return this; + } + + /** + * Sets the display name. + */ + public Builder name(String name) { + this.name = name; + return this; + } + + /** + * Sets the authentication origin. + */ + public Builder origin(UserOrigin origin) { + this.origin = origin; + return this; + } + + /** + * Sets the roles. If called multiple times, the last value is used. + */ + public Builder roles(Set roles) { + this.roles = roles; + return this; + } + + /** + * Adds a single role. Can be called multiple times to build up the role set. + */ + public Builder addRole(String role) { + if (this.roles == null) { + this.roles = new HashSet<>(); + } + this.roles.add(role); + return this; + } + + /** + * Sets the original Spring Authentication object. + */ + public Builder originAuthentication(Authentication originAuthentication) { + this.originAuthentication = originAuthentication; + return this; + } + + /** + * Sets auth-type-specific attributes. + */ + public Builder attributes(Map attributes) { + this.attributes = attributes; + return this; + } + + /** + * Adds a single attribute. Can be called multiple times. + */ + public Builder attribute(String key, Object value) { + if (this.attributes == null) { + this.attributes = new HashMap<>(); + } + this.attributes.put(key, value); + return this; + } + + /** + * Builds the AuthenticatedLogin instance. + */ + public AuthenticatedLogin build() { + return new AuthenticatedLogin(this); + } + } + + @Override + public String toString() { + return "AuthenticatedLogin{" + + "login='" + login + '\'' + + ", name='" + name + '\'' + + ", origin=" + origin + + ", roles=" + roles + + ", attributes=" + attributes + + '}'; + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/authc/DatabaseAuthConfig.java b/src/main/java/org/ohdsi/webapi/security/authc/DatabaseAuthConfig.java index fe0453791..7bd2eb2db 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/DatabaseAuthConfig.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/DatabaseAuthConfig.java @@ -19,7 +19,6 @@ import org.springframework.http.HttpMethod; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.crypto.factory.PasswordEncoderFactories; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; diff --git a/src/main/java/org/ohdsi/webapi/security/authc/LdapAuthConfig.java b/src/main/java/org/ohdsi/webapi/security/authc/LdapAuthConfig.java index bebb8517b..a2fd15192 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/LdapAuthConfig.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/LdapAuthConfig.java @@ -11,7 +11,6 @@ import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.ldap.DefaultSpringSecurityContextSource; import org.springframework.security.ldap.authentication.BindAuthenticator; diff --git a/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java b/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java index 0b28d104a..ff4be929b 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/LoginController.java @@ -13,7 +13,6 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.core.GrantedAuthority; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.*; @@ -80,14 +79,29 @@ public ResponseEntity runAs( @ConditionalOnProperty(prefix = "security.auth.windows", name = "enabled", havingValue = "true") public static class Windows { private final LoginService loginSvc; + private final org.ohdsi.webapi.security.authc.mapper.WindowsGroupToRoleMapper windowsGroupToRoleMapper; - public Windows(LoginService loginSvc) { + public Windows(LoginService loginSvc, + org.ohdsi.webapi.security.authc.mapper.WindowsGroupToRoleMapper windowsGroupToRoleMapper) { this.loginSvc = loginSvc; + this.windowsGroupToRoleMapper = windowsGroupToRoleMapper; } @GetMapping("/user/login/windows") public LoginService.Result login(Authentication authentication) { - return loginSvc.onSuccess(authentication); + // Map Windows groups to WebAPI roles + java.util.Set roles = windowsGroupToRoleMapper.mapGroupsToRoles( + authentication.getAuthorities()); + + AuthenticatedLogin authenticatedLogin = AuthenticatedLogin.builder() + .login(authentication.getName()) + .name(authentication.getName()) + .origin(UserOrigin.WINDOWS) + .roles(roles) + .originAuthentication(authentication) + .build(); + + return loginSvc.onSuccess(authenticatedLogin); } } @@ -108,7 +122,14 @@ public Database(LoginService loginSvc, @GetMapping("/user/login/db") public LoginService.Result login(Authentication authentication) { - return loginSvc.onSuccess(authentication); + AuthenticatedLogin authenticatedLogin = AuthenticatedLogin.builder() + .login(authentication.getName()) + .name(authentication.getName()) + .origin(UserOrigin.DATABASE) + .roles(java.util.Collections.emptySet()) + .originAuthentication(authentication) + .build(); + return loginSvc.onSuccess(authenticatedLogin); } @PostMapping(value = "/user/login/db", consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) @@ -122,7 +143,14 @@ public ResponseEntity loginPost( try { Authentication auth = dbAuthenticationManager.authenticate( new UsernamePasswordAuthenticationToken(login, password)); - return ResponseEntity.ok(loginSvc.onSuccess(auth)); + AuthenticatedLogin authenticatedLogin = AuthenticatedLogin.builder() + .login(auth.getName()) + .name(auth.getName()) + .origin(UserOrigin.DATABASE) + .roles(java.util.Collections.emptySet()) + .originAuthentication(auth) + .build(); + return ResponseEntity.ok(loginSvc.onSuccess(authenticatedLogin)); } catch (AuthenticationException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED) .body(new LoginService.Result(null, null, null, "Invalid credentials")); @@ -138,21 +166,38 @@ public ResponseEntity loginPost( public static class Ldap { private final LoginService loginSvc; + private final org.ohdsi.webapi.security.authc.mapper.LdapGroupToRoleMapper ldapGroupToRoleMapper; private static final Logger log = LoggerFactory.getLogger(Ldap.class); - public Ldap(LoginService loginSvc) { + public Ldap(LoginService loginSvc, + org.ohdsi.webapi.security.authc.mapper.LdapGroupToRoleMapper ldapGroupToRoleMapper) { this.loginSvc = loginSvc; + this.ldapGroupToRoleMapper = ldapGroupToRoleMapper; } @GetMapping("/user/login/ldap") public LoginService.Result login(Authentication authentication) { - List roles = authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) + List groupNames = authentication.getAuthorities().stream() + .map(org.springframework.security.core.GrantedAuthority::getAuthority) .toList(); - log.info("User {} has roles {}", authentication.getName(), roles); - return loginSvc.onSuccess(authentication); + log.info("User {} has LDAP groups {}", authentication.getName(), groupNames); + + // Map LDAP groups to WebAPI roles + java.util.Set roles = ldapGroupToRoleMapper.mapGroupsToRoles( + authentication.getAuthorities(), + org.ohdsi.webapi.security.provisioning.model.LdapProviderType.LDAP); + + AuthenticatedLogin authenticatedLogin = AuthenticatedLogin.builder() + .login(authentication.getName()) + .name(authentication.getName()) + .origin(UserOrigin.LDAP) + .roles(roles) + .originAuthentication(authentication) + .build(); + + return loginSvc.onSuccess(authenticatedLogin); } } diff --git a/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java b/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java index d345868fb..4be592423 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/LoginService.java @@ -2,10 +2,13 @@ import java.time.Instant; import java.util.Date; +import java.util.HashSet; import java.util.List; +import java.util.Set; import java.util.UUID; import org.ohdsi.webapi.security.authz.AuthorizationService; +import org.ohdsi.webapi.security.authz.Role; import org.ohdsi.webapi.security.authz.User; import org.ohdsi.webapi.security.session.SessionProperties; import org.ohdsi.webapi.security.session.SessionService; @@ -16,9 +19,8 @@ import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.oauth2.jwt.Jwt; -import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service public class LoginService { @@ -52,28 +54,94 @@ public LoginService( this.defaultRoles = defaultRoles.stream().filter(s -> !s.isBlank()).toList(); } - public Result onSuccess(Authentication authentication) { - String login = authentication.getName().toLowerCase(); - authorizationService.ensureUserExists(login, login, null, this.defaultRoles); - return mintSession(authentication); - } + /** + * Orchestrates the complete login flow for all authentication types. + * + * This is the single source of truth for login, ensuring: + * 1. User exists in database (created or updated) + * 2. Roles are synchronized based on origin + * 3. Session is created + * 4. JWT is minted + * + * @param authenticatedLogin normalized authentication data from any auth source + * @return Result with JWT and roles for the authenticated user + */ + @Transactional + public Result onSuccess(AuthenticatedLogin authenticatedLogin) { + String login = authenticatedLogin.getLogin().toLowerCase(); + String name = authenticatedLogin.getName(); + UserOrigin origin = authenticatedLogin.getOrigin(); + Set targetRoles = authenticatedLogin.getRoles(); - // Skips ensureUserExists; for callers (e.g. OIDC) that provision the user themselves. - public Result mintSession(Authentication authentication) { - String login = authentication.getName().toLowerCase(); - log.info("LoginService: mintSession: " + login); + log.info("LoginService: onSuccess: {} (origin: {})", login, origin); - String[] roles = authentication.getAuthorities().stream() - .map(GrantedAuthority::getAuthority) - .toArray(String[]::new); + // Ensure user exists in database + authorizationService.ensureUserExists(login, name, origin, this.defaultRoles); + + // Sync roles: align database roles with target roles from this authentication source + syncRoles(login, origin, targetRoles); + // Create session UUID sessionId = sessionService.createSession(login); Instant expiresAt = Instant.now().plus(sessionProps.getExpiration()); + + // Mint JWT String jwt = jwtService.generateToken(login, sessionId.toString(), Date.from(expiresAt)); + // Get final roles from database + String[] roles = authorizationService.getUserRoles(login).stream() + .map(Role::name) + .toArray(String[]::new); + return new Result(login, jwt, roles, "Login successful"); } + /** + * Synchronizes the roles for a user from a specific origin with target roles. + * + * Adds roles present in targetRoles but not in the database, + * and removes roles from the database that are not in targetRoles + * (only for roles assigned by this origin). + * + * @param login user login name + * @param origin authentication origin (for filtering which roles to sync) + * @param targetRoles the set of role names that should be assigned from this origin + */ + @Transactional + private void syncRoles(String login, UserOrigin origin, Set targetRoles) { + List currentOriginRoles; + try { + currentOriginRoles = authorizationService.getRolesByOrigin(login, origin); + } catch (Exception e) { + log.warn("Could not fetch {}-origin roles for user {}: {}", origin, login, e.getMessage()); + return; + } + + // Add roles present in target but not in current + for (String roleName : targetRoles) { + if (!currentOriginRoles.contains(roleName)) { + try { + authorizationService.addUserToRole(roleName, login, origin); + log.info("Sync roles: added role '{}' to user '{}' (origin: {})", roleName, login, origin); + } catch (Exception e) { + log.warn("Sync roles: could not add role '{}' to user '{}': {}", roleName, login, e.getMessage()); + } + } + } + + // Remove roles present in current but not in target (only for this origin) + for (String roleName : currentOriginRoles) { + if (!targetRoles.contains(roleName)) { + try { + authorizationService.removeUserFromRole(roleName, login, origin); + log.info("Sync roles: removed role '{}' from user '{}' (origin: {})", roleName, login, origin); + } catch (Exception e) { + log.warn("Sync roles: could not remove role '{}' from user '{}': {}", roleName, login, e.getMessage()); + } + } + } + } + /** * Impersonate another user. Creates a new session for the target user * and mints a JWT as that user. @@ -157,3 +225,4 @@ public void cleanupSessions() { } } + diff --git a/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java b/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java index 5fa06371f..0d1cdb8e9 100644 --- a/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java +++ b/src/main/java/org/ohdsi/webapi/security/authc/OidcAuthConfig.java @@ -7,8 +7,6 @@ import java.util.List; import java.util.Map; import java.util.Set; - -import org.ohdsi.webapi.security.authz.AuthorizationService; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; @@ -18,10 +16,8 @@ import org.springframework.core.annotation.Order; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.oauth2.client.registration.ClientRegistration; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; import org.springframework.security.oauth2.client.registration.ClientRegistrations; @@ -51,8 +47,8 @@ public class OidcAuthConfig { private static final String DISCOVERY_SUFFIX = "/.well-known/openid-configuration"; private final HttpSecurityShared httpSecurityShared; - private final AuthorizationService authorizationService; private final LoginService loginService; + private final org.ohdsi.webapi.security.authc.mapper.OidcGroupToRoleMapper oidcGroupToRoleMapper; @Value("${security.auth.oidc.clientId}") private String clientId; @@ -81,18 +77,15 @@ public class OidcAuthConfig { @Value("${security.auth.oauth.callback.ui}") private String callbackUi; - @Value("${security.defaultRoles:}") - private List defaultRoles; - @Value("${security.auth.oidc.enabled:false}") private boolean oidcRuntimeEnabled; public OidcAuthConfig(HttpSecurityShared httpSecurityShared, - AuthorizationService authorizationService, - LoginService loginService) { + LoginService loginService, + org.ohdsi.webapi.security.authc.mapper.OidcGroupToRoleMapper oidcGroupToRoleMapper) { this.httpSecurityShared = httpSecurityShared; - this.authorizationService = authorizationService; this.loginService = loginService; + this.oidcGroupToRoleMapper = oidcGroupToRoleMapper; } @Bean @@ -154,32 +147,26 @@ private void handleSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { OidcUser oidcUser = (OidcUser) authentication.getPrincipal(); - // Lowercase so the DB row login matches mintSession's JWT subject (which lowercases via getName()). String login = oidcUser.getSubject().toLowerCase(); String name = firstNonBlank(oidcUser.getFullName(), oidcUser.getEmail(), login); log.info("OIDC: Authenticated user sub={}", login); - List filteredDefaults = defaultRoles.stream().filter(s -> !s.isBlank()).toList(); - authorizationService.ensureUserExists(login, name, UserOrigin.OIDC, filteredDefaults); + // Extract and map roles from token claims + Set roles = oidcGroupToRoleMapper.extractAndMapRoles( + oidcUser.getClaims(), + rolesClaim, + rolesToUpperCase); - List rawRoles = extractRoles(oidcUser.getClaims(), rolesClaim, rolesToUpperCase); - List idpRoles = authorizationService.filterToExistingRoles(rawRoles); - if (rawRoles.size() != idpRoles.size()) { - log.debug("OIDC: dropped {} unknown roles from token for user {}", - rawRoles.size() - idpRoles.size(), login); - } - if (!idpRoles.isEmpty()) { - log.info("OIDC: Syncing roles from token for user {}: {}", login, idpRoles); - } - authorizationService.syncOidcRoles(login, idpRoles); + AuthenticatedLogin authenticatedLogin = AuthenticatedLogin.builder() + .login(login) + .name(name) + .origin(UserOrigin.OIDC) + .roles(roles) + .originAuthentication(authentication) + .build(); - List authorities = idpRoles.stream() - .map(SimpleGrantedAuthority::new) - .toList(); -Authentication wrapped = new UsernamePasswordAuthenticationToken(login, null, authorities); - // mintSession — not onSuccess — so onSuccess's ensureUserExists doesn't overwrite the display name. - LoginService.Result result = loginService.mintSession(wrapped); + LoginService.Result result = loginService.onSuccess(authenticatedLogin); response.sendRedirect(appendFragmentParam(callbackUi, "token", result.jwt())); } @@ -301,24 +288,21 @@ public static class OpenidDirect { private static final Logger log = LoggerFactory.getLogger(OpenidDirect.class); - private final AuthorizationService authorizationService; private final LoginService loginService; + private final org.ohdsi.webapi.security.authc.mapper.OidcGroupToRoleMapper oidcGroupToRoleMapper; private final JwtDecoder jwtDecoder; - private final List defaultRoles; private final String rolesClaim; private final boolean rolesToUpperCase; public OpenidDirect( - AuthorizationService authorizationService, LoginService loginService, + org.ohdsi.webapi.security.authc.mapper.OidcGroupToRoleMapper oidcGroupToRoleMapper, @Value("${security.auth.oidc.enabled:false}") boolean enabled, @Value("${security.auth.oidc.url}") String discoveryOrIssuerUrl, @Value("${security.auth.oidc.rolesClaim:}") String rolesClaim, - @Value("${security.auth.oidc.rolesToUpperCase:true}") boolean rolesToUpperCase, - @Value("${security.defaultRoles:}") List defaultRoles) { - this.authorizationService = authorizationService; + @Value("${security.auth.oidc.rolesToUpperCase:true}") boolean rolesToUpperCase) { this.loginService = loginService; - this.defaultRoles = defaultRoles.stream().filter(s -> !s.isBlank()).toList(); + this.oidcGroupToRoleMapper = oidcGroupToRoleMapper; this.rolesClaim = rolesClaim; this.rolesToUpperCase = rolesToUpperCase; if (!enabled || discoveryOrIssuerUrl == null || discoveryOrIssuerUrl.isBlank()) { @@ -370,17 +354,21 @@ public ResponseEntity login( log.info("OIDC direct: authenticated user sub={}", login); - authorizationService.ensureUserExists(login, name, UserOrigin.OIDC, defaultRoles); - - List rawRoles = extractRoles(jwt.getClaims(), rolesClaim, rolesToUpperCase); - List idpRoles = authorizationService.filterToExistingRoles(rawRoles); - authorizationService.syncOidcRoles(login, idpRoles); + // Extract and map roles from token claims + Set roles = oidcGroupToRoleMapper.extractAndMapRoles( + jwt.getClaims(), + rolesClaim, + rolesToUpperCase); + + AuthenticatedLogin authenticatedLogin = AuthenticatedLogin.builder() + .login(login) + .name(name) + .origin(UserOrigin.OIDC) + .roles(roles) + .originAuthentication(null) // Direct JWT flow doesn't have Spring Authentication + .build(); - List authorities = idpRoles.stream() - .map(SimpleGrantedAuthority::new) - .toList(); - Authentication wrapped = new UsernamePasswordAuthenticationToken(login, null, authorities); - LoginService.Result result = loginService.mintSession(wrapped); + LoginService.Result result = loginService.onSuccess(authenticatedLogin); return ResponseEntity.ok() .header("Bearer", result.jwt()) diff --git a/src/main/java/org/ohdsi/webapi/security/authc/mapper/LdapGroupToRoleMapper.java b/src/main/java/org/ohdsi/webapi/security/authc/mapper/LdapGroupToRoleMapper.java new file mode 100644 index 000000000..ba563da29 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/authc/mapper/LdapGroupToRoleMapper.java @@ -0,0 +1,65 @@ +package org.ohdsi.webapi.security.authc.mapper; + +import java.util.Collections; +import java.util.Set; + +import org.ohdsi.webapi.security.provisioning.model.LdapProviderType; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +/** + * Maps LDAP groups (from authentication authorities) to WebAPI role names. + * + * PHASE 1 PLACEHOLDER: Currently returns empty set to support the unified login pipeline. + * Phase 2 will implement actual group-to-role mapping using the sec_role_group table + * and RoleGroupEntity infrastructure. + * + * The mapping infrastructure is currently entangled with the LDAP import job in the + * security.provisioning package. Phase 2 will refactor this to provide a clean + * group-to-role mapping service. + */ +@Component +public class LdapGroupToRoleMapper { + + private static final Logger log = LoggerFactory.getLogger(LdapGroupToRoleMapper.class); + + + /** + * Maps LDAP groups from the authentication authorities to WebAPI role names. + * + * PHASE 1: Returns empty set (no mapping). Phase 2 will implement actual mapping. + * + * @param authorities the Spring authorities from LDAP authentication (usually group names) + * @param providerType the type of LDAP provider (LDAP or ACTIVE_DIRECTORY) + * @return Empty set of role names (placeholder for phase 1) + */ + public Set mapGroupsToRoles(java.util.Collection authorities, LdapProviderType providerType) { + if (authorities == null || authorities.isEmpty()) { + return Collections.emptySet(); + } + + // PHASE 1: No group mapping - users will be assigned only default roles + log.debug("LDAP group mapping not yet implemented - phase 1 placeholder"); + return Collections.emptySet(); + } + + /** + * Maps LDAP group names to WebAPI role names. + * + * PHASE 1: Returns empty set (no mapping). Phase 2 will implement actual mapping. + * + * @param groupNames the LDAP group names to map + * @param providerType the type of LDAP provider (LDAP or ACTIVE_DIRECTORY) + * @return Empty set of role names (placeholder for phase 1) + */ + public Set mapGroupsToRoles(Set groupNames, LdapProviderType providerType) { + if (groupNames == null || groupNames.isEmpty()) { + return Collections.emptySet(); + } + + // PHASE 1: No group mapping - users will be assigned only default roles + return Collections.emptySet(); + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/authc/mapper/OidcGroupToRoleMapper.java b/src/main/java/org/ohdsi/webapi/security/authc/mapper/OidcGroupToRoleMapper.java new file mode 100644 index 000000000..da04b0a93 --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/authc/mapper/OidcGroupToRoleMapper.java @@ -0,0 +1,44 @@ +package org.ohdsi.webapi.security.authc.mapper; + +import java.util.Collections; +import java.util.Map; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Component; + +/** + * Extracts and maps OIDC token claims to WebAPI role names. + * + * PHASE 1 PLACEHOLDER: Currently returns empty set to support the unified login pipeline. + * Phase 2 will implement actual role claim extraction and filtering. + * + * The mapping infrastructure needs further clarification about which token formats + * are supported and how to safely extract roles from various OIDC providers. + */ +@Component +public class OidcGroupToRoleMapper { + + private static final Logger log = LoggerFactory.getLogger(OidcGroupToRoleMapper.class); + + /** + * Extracts and maps OIDC roles from JWT claims to WebAPI role names. + * + * PHASE 1: Returns empty set (no mapping). Phase 2 will implement actual extraction. + * + * @param claims the JWT claims map from the OIDC token + * @param roleClaimPath the dot-separated path to the roles claim (e.g., "realm_access.roles") + * @param toUpperCase whether to uppercase role names before filtering + * @return Empty set of role names (placeholder for phase 1) + */ + public Set extractAndMapRoles(Map claims, String roleClaimPath, boolean toUpperCase) { + if (claims == null || claims.isEmpty()) { + return Collections.emptySet(); + } + + // PHASE 1: No role claim extraction - users will be assigned only default roles + log.debug("OIDC role claim extraction not yet implemented - phase 1 placeholder"); + return Collections.emptySet(); + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/authc/mapper/WindowsGroupToRoleMapper.java b/src/main/java/org/ohdsi/webapi/security/authc/mapper/WindowsGroupToRoleMapper.java new file mode 100644 index 000000000..92ffc976e --- /dev/null +++ b/src/main/java/org/ohdsi/webapi/security/authc/mapper/WindowsGroupToRoleMapper.java @@ -0,0 +1,64 @@ +package org.ohdsi.webapi.security.authc.mapper; + +import java.util.Collections; +import java.util.Set; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.stereotype.Component; + +/** + * Maps Windows groups (from Kerberos/SPNEGO authentication) to WebAPI role names. + * + * PHASE 1 PLACEHOLDER: Currently returns empty set to support the unified login pipeline. + * Phase 2 will implement actual group-to-role mapping using the sec_role_group table + * and the Active Directory provider configuration. + * + * Windows groups typically come from Active Directory, but the mapping infrastructure + * is currently entangled with the LDAP import job in the security.provisioning package. + * Phase 2 will refactor this to provide a clean group-to-role mapping service. + */ +@Component +public class WindowsGroupToRoleMapper { + + private static final Logger log = LoggerFactory.getLogger(WindowsGroupToRoleMapper.class); + + + /** + * Maps Windows groups from the authentication authorities to WebAPI role names. + * + * PHASE 1: Returns empty set (no mapping). Phase 2 will implement actual mapping. + * + * @param authorities the Spring authorities from Windows authentication (group names) + * @return Empty set of role names (placeholder for phase 1) + */ + public Set mapGroupsToRoles(java.util.Collection authorities) { + if (authorities == null || authorities.isEmpty()) { + return Collections.emptySet(); + } + + // PHASE 1: No group mapping - users will be assigned only default roles + log.debug("Windows group mapping not yet implemented - phase 1 placeholder"); + return Collections.emptySet(); + } + + /** + * Maps Windows group names to WebAPI role names. + * + * PHASE 1: Returns empty set (no mapping). Phase 2 will implement actual mapping. + * + * Windows groups are typically stored in Active Directory. + * + * @param groupNames the Windows group names to map + * @return Empty set of role names (placeholder for phase 1) + */ + public Set mapGroupsToRoles(Set groupNames) { + if (groupNames == null || groupNames.isEmpty()) { + return Collections.emptySet(); + } + + // PHASE 1: No group mapping - users will be assigned only default roles + return Collections.emptySet(); + } +} diff --git a/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java b/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java index efede1788..807114d22 100644 --- a/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java +++ b/src/main/java/org/ohdsi/webapi/security/authz/AuthorizationService.java @@ -83,7 +83,7 @@ public void syncOidcRoles(String login, List targetRoleNames) { org.slf4j.Logger log = org.slf4j.LoggerFactory.getLogger(AuthorizationService.class); List currentOidcRoleNames; try { - currentOidcRoleNames = getOidcOriginRoles(login); + currentOidcRoleNames = getRolesByOrigin(login, UserOrigin.OIDC); } catch (Exception e) { log.warn("OIDC sync: could not fetch OIDC-origin roles for user {}: {}", login, e.getMessage()); return; @@ -178,7 +178,7 @@ public List getUserRoles(Long userId) throws Exception { } @Transactional(readOnly = true) - public List getUserRoles(String login) throws Exception { + public List getUserRoles(String login) { UserEntity user = this.userService.getUserByLogin(login).orElseThrow(); Set roleEntities = this.roleService.getUserRoles(user); ArrayList roles = new ArrayList<>(); @@ -254,10 +254,10 @@ public void removeUserFromRole(String roleName, String login, UserOrigin origin) // @Transactional required: UserEntity.userRoles is FetchType.LAZY and getUserByLogin closes its own txn. @Transactional(readOnly = true) - public List getOidcOriginRoles(String login) { + public List getRolesByOrigin(String login, UserOrigin origin) { UserEntity user = userService.getUserByLogin(login).orElseThrow(); return user.getUserRoles().stream() - .filter(ur -> ur.getOrigin() == UserOrigin.OIDC) + .filter(ur -> ur.getOrigin() == origin) .map(ur -> ur.getRole()) .filter(role -> Boolean.TRUE.equals(role.isSystemRole())) .map(role -> role.getName())