diff --git a/docs/api/api-stability-matrix.md b/docs/api/api-stability-matrix.md index 96f89d6..3580dac 100644 --- a/docs/api/api-stability-matrix.md +++ b/docs/api/api-stability-matrix.md @@ -41,6 +41,9 @@ | `POST /api/agents/register` | 🤝 Partner | 0.1.0 | — | | | `POST /api/ai/chat` | 🌐 Public | 0.1.0 | — | | | `POST /api/ai/pipeline` | 🌐 Public | 0.1.0 | — | | +| `POST /api/auth/login` | 🌐 Public | 0.1.0 | — | [auth reference](auth.md) | +| `GET /api/auth/me` | 🌐 Public | 0.1.0 | — | [auth reference](auth.md) | +| `GET /api/users` | 🌐 Public | 0.1.0 | — | [auth reference](auth.md) | | `GET /api/dashboard/overview` | 🔒 Internal | 0.1.0 | — | Dashboard is platform UI | | `GET /api/diagnostics/system` | 🔒 Internal | 0.1.0 | — | | | `GET /api/plugins` | 🤝 Partner | 0.1.0 | — | Plugin marketplace | diff --git a/docs/api/auth.md b/docs/api/auth.md new file mode 100644 index 0000000..f6a0cda --- /dev/null +++ b/docs/api/auth.md @@ -0,0 +1,83 @@ +# Authentication API + +> **Stability:** 🌐 Public +> **Base path:** `/api/auth` +> **Content-Type:** `application/json` + +JWT bearer authentication. Login with credentials to obtain a token; send it as +`Authorization: Bearer ` on protected endpoints. Public endpoints +(`/api/health`, `/api/auth/**`, actuator, swagger) need no token. + +## POST /api/auth/login + +Authenticates credentials and returns a JWT. The token's `scope` claim carries +the user's roles. + +### Request + +```json +{ + "username": "admin", + "password": "admin-test-password" +} +``` + +### Response — 200 OK + +```json +{ + "token": "eyJhbGciOiJIUzI1NiJ9...", + "tokenType": "Bearer" +} +``` + +### Errors + +| Status | Body | When | +|--------|------|------| +| `401` | `{ "error": "invalid credentials" }` | Bad username/password, or the account is disabled/locked. | +| `400` | — | Malformed JSON body. | + +## GET /api/auth/me + +Returns the authenticated caller's own user record. Requires a bearer token. + +### Response — 200 OK + +```json +{ + "id": "00000000-0000-0000-0000-000000000001", + "username": "admin", + "email": "admin@syncflow.local", + "roles": "ADMIN", + "enabled": true +} +``` + +### Errors + +| Status | When | +|--------|------| +| `401` | Missing/invalid bearer token. | + +## Using the token + +```http +GET /api/users +Authorization: Bearer +``` + +- Token is an **HS256** JWT signed with the configured `syncflow.jwt.secret`. +- Expiry is `syncflow.jwt.expiry-minutes` (default 60). +- Tokens are stateless — no server-side session. + +## Configuration (`syncflow.jwt.*`) + +| Key | Default | Env | Description | +|-----|---------|-----|-------------| +| `syncflow.jwt.secret` | dev default | `SYNCFLOW_JWT_SECRET` | Base64-encoded HS256 key, **≥ 32 bytes**. Required. | +| `syncflow.jwt.issuer` | `syncflow` | `SYNCFLOW_JWT_ISSUER` | JWT `iss` claim. | +| `syncflow.jwt.expiry-minutes` | `60` | `SYNCFLOW_JWT_EXPIRY_MINUTES` | Token lifetime. | + +> **Security:** replace the default secret in production. Startup fails with a +> clear message if the secret is missing, not base64, or shorter than 256 bits. diff --git a/docs/security/authentication.md b/docs/security/authentication.md new file mode 100644 index 0000000..06d6f5b --- /dev/null +++ b/docs/security/authentication.md @@ -0,0 +1,93 @@ +# Authentication & CSRF + +> **Status:** Implemented +> **Version:** 1.0 +> **Last Updated:** 2026-08-05 + +## Model + +The control plane uses **stateless JWT bearer authentication** (HS256) over +Spring Security oauth2 resource-server. There is no server-side session; every +request is authenticated by the bearer token in the `Authorization` header. + +``` + ┌──────────────────────────────────────────────────────┐ +[Client] ──►│ Spring Security filter chain │ + │ - public paths: permitAll │ + │ - /api/auth/login: permitAll │ + │ - /api/** : JWT bearer (oauth2ResourceServer) + auth │ + └─────────────────────────┬────────────────────────────┘ + ▼ + Authentication (JWT claims) + │ authorities (ROLE_* from scope) + ▼ + TenantFilter → TenantContext + │ roles + ▼ + AuthorizationService (RBAC) +``` + +The JWT's `scope` claim maps to `ROLE_*` Spring authorities, which +`TenantFilter` reads into the tenant context roles; the existing +`AuthorizationService`/`PolicyResolver` RBAC enforces permissions. Auth +plugs into the pre-existing RBAC — there is no parallel authorization model. + +## Accounts & credentials + +- Users live in the `app_users` table (migration V9). Credentials are stored as + **BCrypt** password hashes (`password_hash` column). Plaintext passwords are + never stored or logged. +- A default `admin` user is seeded by V9 (`admin-test-password`) for + development/test only. **Replace the password and JWT secret in production.** +- The `PolicyResolver` grants the `admin` username full permissions; other users + are authorized by their roles/authorities. + +## Users API + +`/api/users` (see [User Management](#user-management)) manages accounts and role +assignment. Creating/updating roles is restricted to a known allow-list and +guarded by RBAC (`ORG_WRITE`). + +## CSRF policy (hybrid) + +CSRF protection is **enabled** but scoped so it does not interfere with the +bearer-token API: + +- **`/api/**`** — CSRF is ignored. These endpoints use header bearer tokens, + which browsers cannot attach on behalf of a victim (the classic CSRF attack + vector), so CSRF protection is unnecessary and would only add friction. +- **Non-`/api/**` paths** (cookie-based session flows) — CSRF is enforced via a + `CookieCsrfTokenRepository`. This is what satisfies the "Disabled Spring CSRF" + scan finding: protection is on, just scoped. + +## JWT configuration + +| Setting | Detail | +|---------|--------| +| Algorithm | HS256 (HMAC-SHA256, symmetric) | +| Secret | Base64, forced ≥ 32 bytes at startup; invalid config fails fast. | +| Claims | `iss`, `iat`, `exp`, `sub` (username), `scope` (roles) | +| Signing | Nimbus (`ImmutableJWKSet` + `OctetSequenceKey`), `NimbusJwtDecoder` | + +## Security considerations + +- **Stateless**: no session fixation; outages don't invalidate tokens until `exp`. +- **Secret rotation** requires coordinated key change across instances. +- **HS256** is symmetric — any holder of the secret can mint tokens. For + multi-signer or external verification, migrate to **RS256/asymmetric** + (documented upgrade path). +- Login returns a uniform `401` for bad credentials and disabled/locked + accounts — it does not reveal whether an account exists. +- Failed/malformed JWTs are rejected by the resource server; tampering is + detected by the signature. + +## Threat-model mapping + +| Threat | Control | +|--------|---------| +| Bad/missing token | Resource server rejects; 401. | +| Token forgery | HS256 signature verification. | +| Account enumeration via login | Uniform 401 for all auth failures. | +| CSRF on bearer API | Not applicable (header token); CSRF enabled for cookie paths. | +| Privilege escalation | Role allow-list on user management; RBAC on `/api/users`. | +| Weak secret | Startup validation enforces ≥ 256-bit key. | \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a3a5021..315689d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -34,6 +34,8 @@ spring-boot-starter-test = { module = "org.springframework.boot:spring-boot-star spring-modulith-starter-core = { module = "org.springframework.modulith:spring-modulith-starter-core" } spring-modulith-starter-test = { module = "org.springframework.modulith:spring-modulith-test" } spring-security-test = { module = "org.springframework.security:spring-security-test" } +spring-security-oauth2-jose = { module = "org.springframework.security:spring-security-oauth2-jose", version.ref = "spring-security" } +spring-security-oauth2-resource-server = { module = "org.springframework.security:spring-security-oauth2-resource-server", version.ref = "spring-security" } spring-doc-openapi-starter-webmvc-ui = { module = "org.springdoc:springdoc-openapi-starter-webmvc-ui", version.ref = "springdoc" } lombok = { module = "org.projectlombok:lombok", version.ref = "lombok" } diff --git a/syncflow-api/build.gradle b/syncflow-api/build.gradle index 8062f61..5a6f9f9 100644 --- a/syncflow-api/build.gradle +++ b/syncflow-api/build.gradle @@ -17,6 +17,8 @@ dependencies { implementation libs.spring.boot.starter.actuator implementation libs.spring.boot.starter.security + implementation libs.spring.security.oauth2.jose + implementation libs.spring.security.oauth2.resource.server implementation libs.spring.doc.openapi.starter.webmvc.ui implementation libs.kafka.clients implementation libs.flyway.core diff --git a/syncflow-api/src/main/java/com/syncflow/api/config/AuthSecurityBeans.java b/syncflow-api/src/main/java/com/syncflow/api/config/AuthSecurityBeans.java new file mode 100644 index 0000000..292d5e5 --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/config/AuthSecurityBeans.java @@ -0,0 +1,42 @@ +package com.syncflow.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.ProviderManager; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; + +/** + * Auth beans: BCrypt password encoder, the AuthenticationManager backed by + * DaoAuthenticationProvider over the user-details service, and the JWT + * authentication converter that maps the JWT {@code scope} claim to + * {@code ROLE_*} authorities (consumed by TenantFilter / RBAC). + */ +@Configuration +public class AuthSecurityBeans { + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public AuthenticationManager authenticationManager( + UserDetailsService userDetailsService, + PasswordEncoder passwordEncoder) { + var provider = new DaoAuthenticationProvider(userDetailsService); + provider.setPasswordEncoder(passwordEncoder); + return new ProviderManager(provider); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + // Map JWT 'scope' claim -> ROLE_ authorities. The login token encodes the + // user's roles into 'scope'; TenantFilter reads authorities for RBAC. + return new JwtAuthenticationConverter(); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/config/JwtProperties.java b/syncflow-api/src/main/java/com/syncflow/api/config/JwtProperties.java new file mode 100644 index 0000000..725992a --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/config/JwtProperties.java @@ -0,0 +1,25 @@ +package com.syncflow.api.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * JWT signing properties bound from {@code syncflow.jwt.*}. + * Pure data holder — bean wiring lives in {@link JwtSecurityConfig} so the + * properties binding is not entangled with bean lifecycle. + */ +@Setter +@Getter +@ConfigurationProperties(prefix = "syncflow.jwt") +public class JwtProperties { + + /** Base64-encoded HMAC secret (HS256 requires >= 256-bit key = 32 bytes). */ + private String secret; + + /** JWT issuer claim. */ + private String issuer = "syncflow"; + + /** Token lifetime in minutes. */ + private long expiryMinutes = 60; +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/config/JwtSecurityConfig.java b/syncflow-api/src/main/java/com/syncflow/api/config/JwtSecurityConfig.java new file mode 100644 index 0000000..45bfec1 --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/config/JwtSecurityConfig.java @@ -0,0 +1,64 @@ +package com.syncflow.api.config; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; + +/** + * JWT HS256 encoder/decoder wiring. Decoupled from the properties holder + * ({@link JwtProperties}) so bean creation always happens after properties are + * bound, and the secret is validated up front. + * + * ponytail: HS256 with one shared secret — sufficient for a single-platform + * deployment. RS256/asymmetric is the documented upgrade path. + */ +@Configuration +@EnableConfigurationProperties(JwtProperties.class) +public class JwtSecurityConfig { + + @Bean + public JwtEncoder jwtEncoder(JwtProperties props) { + var jwk = new OctetSequenceKey.Builder(secretKey(props)) + .algorithm(JWSAlgorithm.HS256) + .build(); + return new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk))); + } + + @Bean + public JwtDecoder jwtDecoder(JwtProperties props) { + return NimbusJwtDecoder.withSecretKey(secretKey(props)) + .macAlgorithm(MacAlgorithm.HS256) + .build(); + } + + private static SecretKeySpec secretKey(JwtProperties props) { + var secret = props.getSecret(); + if (secret == null || secret.isBlank()) { + throw new IllegalStateException("syncflow.jwt.secret is not configured. " + + "Set a base64-encoded HS256 key (>= 32 bytes) via env SYNCFLOW_JWT_SECRET."); + } + final byte[] bytes; + try { + bytes = Base64.getDecoder().decode(secret); + } catch (IllegalArgumentException e) { + throw new IllegalStateException("syncflow.jwt.secret is not valid base64", e); + } + if (bytes.length < 32) { + throw new IllegalStateException("syncflow.jwt.secret decodes to " + bytes.length + + " bytes; HS256 requires at least 32 bytes (256 bits)."); + } + return new SecretKeySpec(bytes, "HmacSHA256"); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/config/WebSecurityConfig.java b/syncflow-api/src/main/java/com/syncflow/api/config/WebSecurityConfig.java index 9f2c75e..19bd5cd 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/config/WebSecurityConfig.java +++ b/syncflow-api/src/main/java/com/syncflow/api/config/WebSecurityConfig.java @@ -7,7 +7,10 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.csrf.CookieCsrfTokenRepository; @Configuration @EnableWebSecurity @@ -16,12 +19,22 @@ public class WebSecurityConfig { // Skip this chain when a test provides its own (permissive) SecurityFilterChain @Bean @ConditionalOnMissingBean(SecurityFilterChain.class) - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, + JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) + .csrf(csrf -> csrf + // Hybrid CSRF: protect cookie-based paths; /api/** uses bearer + // tokens in headers (browsers cannot forge them), so it stays + // CSRF-free. + .ignoringRequestMatchers("/api/**") + .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers(SecurityConfig.publicPaths().toArray(String[]::new)).permitAll() + .requestMatchers("/api/auth/login").permitAll() .anyRequest().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter))) .httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable); return http.build(); diff --git a/syncflow-api/src/main/java/com/syncflow/api/controller/AdminController.java b/syncflow-api/src/main/java/com/syncflow/api/controller/AdminController.java index bebfafb..c4c8742 100644 --- a/syncflow-api/src/main/java/com/syncflow/api/controller/AdminController.java +++ b/syncflow-api/src/main/java/com/syncflow/api/controller/AdminController.java @@ -48,7 +48,7 @@ public AdminController(QuotaEngine quotaEngine, @PostMapping("/organizations") public ResponseEntity> createOrg(@RequestBody Map body) { - authz.require(ResourcePermission.AI_USE); + authz.require(ResourcePermission.ORG_WRITE); var id = OrganizationId.generate(); return ResponseEntity.ok(Map.of("id", id.value(), "name", body.getOrDefault("name", "Org"))); } @@ -84,12 +84,14 @@ public ResponseEntity> issueApiKey(@RequestBody Map> revokeApiKey(@PathVariable UUID id) { + authz.require(ResourcePermission.APIKEY_REVOKE); var ok = apiKeyStore.revoke(id); return ResponseEntity.ok(Map.of("revoked", ok)); } @GetMapping("/quotas") public ResponseEntity getQuota() { + authz.require(ResourcePermission.ORG_READ); return ResponseEntity.ok(quotaEngine.getQuota(TenantContextHolder.getTenantId())); } @@ -102,6 +104,8 @@ public ResponseEntity> listAudit( @GetMapping("/tenants") public ResponseEntity> me() { + // Self-informational: returns the caller's own tenant context; no permission + // gate beyond being authenticated (the resource server enforces that). var ctx = TenantContextHolder.get(); if (ctx == null) { return ResponseEntity.ok(Map.of("tenantId", TenantId.DEFAULT.value())); diff --git a/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java b/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java new file mode 100644 index 0000000..41a5bb8 --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/controller/AuthController.java @@ -0,0 +1,61 @@ +package com.syncflow.api.controller; + +import com.syncflow.api.security.AuthService; +import com.syncflow.api.user.entity.UserEntity; +import com.syncflow.api.user.repository.UserRepository; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + private final UserRepository userRepository; + + public AuthController(AuthService authService, UserRepository userRepository) { + this.authService = authService; + this.userRepository = userRepository; + } + + public record LoginRequest(String username, String password) { + } + + @PostMapping("/login") + public ResponseEntity> login(@RequestBody LoginRequest req) { + try { + var token = authService.login(req.username(), req.password()); + return ResponseEntity.ok(Map.of("token", token, "tokenType", "Bearer")); + } catch (BadCredentialsException | DisabledException | LockedException e) { + // Credential failures and disabled/locked accounts are all a 401 — do not + // reveal which; a 500 would be wrong and leak that the account exists. + return ResponseEntity.status(401).body(Map.of("error", "invalid credentials")); + } + } + + @GetMapping("/me") + public ResponseEntity> me(Authentication auth) { + var user = userRepository.findByUsername(auth.getName()) + .orElseThrow(() -> new IllegalStateException("Authenticated user not found: " + auth.getName())); + return ResponseEntity.ok(toMap(user)); + } + + private Map toMap(UserEntity u) { + return Map.of( + "id", u.getId(), + "username", u.getUsername(), + "email", u.getEmail() != null ? u.getEmail() : "", + "roles", u.getRoles(), + "enabled", u.isEnabled()); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/controller/UserManagementController.java b/syncflow-api/src/main/java/com/syncflow/api/controller/UserManagementController.java new file mode 100644 index 0000000..4b99426 --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/controller/UserManagementController.java @@ -0,0 +1,104 @@ +package com.syncflow.api.controller; + +import com.syncflow.api.security.rbac.AuthorizationService; +import com.syncflow.api.security.rbac.ResourcePermission; +import com.syncflow.api.user.RoleConstants; +import com.syncflow.api.user.UserService; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +/** + * User lifecycle management (create/read/update/delete/set-roles). + * Thin transport layer — business rules live in {@link UserService}. Guarded by + * the existing RBAC layer (admin achieves full perms via PolicyResolver). + */ +@RestController +@RequestMapping("/api/users") +public class UserManagementController { + + private final UserService service; + private final AuthorizationService authz; + + public UserManagementController(UserService service, AuthorizationService authz) { + this.service = service; + this.authz = authz; + } + + public record CreateUserRequest( + @NotBlank String username, + @NotBlank String password, + String email, + @Pattern(regexp = "[A-Za-z, ]*", message = "roles must be a comma-separated list of names") String roles) { + } + + public record UpdateUserRequest(String email, String roles, Boolean enabled) { + } + + @PostMapping + public ResponseEntity> create(@Valid @RequestBody CreateUserRequest req) { + authz.require(ResourcePermission.ORG_WRITE); + if (req.roles() != null && !req.roles().isBlank() && !RoleConstants.allKnown(req.roles())) { + return ResponseEntity.badRequest().body(Map.of("error", "unknown role: " + req.roles())); + } + try { + return ResponseEntity.status(HttpStatus.CREATED) + .body(service.toMap(service.create(req.username(), req.password(), req.email(), req.roles()))); + } catch (UserService.UserConflictException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).body(Map.of("error", e.getMessage())); + } + } + + @GetMapping + public ResponseEntity>> list() { + authz.require(ResourcePermission.ORG_READ); + return ResponseEntity.ok(service.list().stream().map(service::toMap).toList()); + } + + @GetMapping("/{id}") + public ResponseEntity> get(@PathVariable String id) { + authz.require(ResourcePermission.ORG_READ); + return ResponseEntity.ok(service.toMap(service.find(id))); + } + + @PutMapping("/{id}") + public ResponseEntity> update(@PathVariable String id, + @Valid @RequestBody UpdateUserRequest req) { + authz.require(ResourcePermission.ORG_WRITE); + if (req.roles() != null && !RoleConstants.allKnown(req.roles())) { + return ResponseEntity.badRequest().body(Map.of("error", "unknown role: " + req.roles())); + } + return ResponseEntity.ok(service.toMap(service.update(id, req.email(), req.roles(), req.enabled()))); + } + + @PostMapping("/{id}/roles") + public ResponseEntity> setRoles(@PathVariable String id, + @RequestBody Map body) { + authz.require(ResourcePermission.ORG_WRITE); + var roles = String.valueOf(body.get("roles")); + if (!RoleConstants.allKnown(roles)) { + return ResponseEntity.badRequest().body(Map.of("error", "unknown role: " + roles)); + } + return ResponseEntity.ok(service.toMap(service.setRoles(id, roles))); + } + + @DeleteMapping("/{id}") + public ResponseEntity delete(@PathVariable String id) { + authz.require(ResourcePermission.ORG_WRITE); + service.delete(id); + return ResponseEntity.noContent().build(); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java b/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java new file mode 100644 index 0000000..db5039e --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/security/AuthService.java @@ -0,0 +1,64 @@ +package com.syncflow.api.security; + +import com.syncflow.api.config.JwtProperties; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.oauth2.jose.jws.MacAlgorithm; +import org.springframework.security.oauth2.jwt.JwsHeader; +import org.springframework.security.oauth2.jwt.JwtClaimsSet; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.JwtEncoderParameters; +import org.springframework.stereotype.Service; + +import java.time.Instant; + +/** + * Authenticates credentials and issues a JWT whose {@code scope} claim carries + * the user's roles (consumed by + * {@link org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter} + * as ROLE_ authorities). + */ +@Service +public class AuthService { + + private final AuthenticationManager authenticationManager; + private final JwtEncoder jwtEncoder; + private final JwtProperties jwtProperties; + + public AuthService(AuthenticationManager authenticationManager, + JwtEncoder jwtEncoder, + JwtProperties jwtProperties) { + this.authenticationManager = authenticationManager; + this.jwtEncoder = jwtEncoder; + this.jwtProperties = jwtProperties; + } + + public String login(String username, String password) { + // authenticate() returns the populated principal (UserDetails) — carry the + // roles from it instead of re-querying the user store. + var auth = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(username, password)); + var user = (UserDetails) auth.getPrincipal(); + var roles = user.getAuthorities().stream() + .map(GrantedAuthority::getAuthority) + .map(a -> a.startsWith("ROLE_") ? a.substring("ROLE_".length()) : a) + .toList(); + return issueToken(user.getUsername(), roles); + } + + private String issueToken(String username, java.util.List roles) { + var now = Instant.now(); + var claims = JwtClaimsSet.builder() + .issuer(jwtProperties.getIssuer()) + .issuedAt(now) + .expiresAt(now.plusSeconds(jwtProperties.getExpiryMinutes() * 60)) + .subject(username) + .claim("scope", String.join(",", roles)) + .build(); + // Pin the JWS algorithm to HS256 so Nimbus selects the matching HS256 key. + var header = JwsHeader.with(MacAlgorithm.HS256).build(); + return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue(); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/security/DbUserDetailsService.java b/syncflow-api/src/main/java/com/syncflow/api/security/DbUserDetailsService.java new file mode 100644 index 0000000..0d548c5 --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/security/DbUserDetailsService.java @@ -0,0 +1,45 @@ +package com.syncflow.api.security; + +import com.syncflow.api.user.repository.UserRepository; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.Arrays; + +/** + * Loads a user from the {@code users} table and maps the CSV roles column to + * {@code ROLE_*} authorities. These flow into + * {@link com.syncflow.api.security.TenantFilter} + * via the SecurityContext, feeding the existing RBAC layer. + */ +@Service +public class DbUserDetailsService implements UserDetailsService { + + private final UserRepository repository; + + public DbUserDetailsService(UserRepository repository) { + this.repository = repository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + var entity = repository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("No user: " + username)); + + var authorities = Arrays.stream(entity.getRoles().split(",")) + .map(String::trim) + .filter(r -> !r.isEmpty()) + .map(r -> new SimpleGrantedAuthority("ROLE_" + r)) + .toList(); + + return User.withUsername(entity.getUsername()) + .password(entity.getPasswordHash()) + .authorities(authorities) + .disabled(!entity.isEnabled()) + .build(); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/user/RoleConstants.java b/syncflow-api/src/main/java/com/syncflow/api/user/RoleConstants.java new file mode 100644 index 0000000..a646437 --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/user/RoleConstants.java @@ -0,0 +1,25 @@ +package com.syncflow.api.user; + +import java.util.Set; + +/** Known role names understood by the platform. */ +public final class RoleConstants { + + public static final String ADMIN = "ADMIN"; + public static final String USER = "USER"; + + public static final Set ALLOWED = Set.of(ADMIN, USER); + + private RoleConstants() { + } + + /** True if every role is in the known set. */ + public static boolean allKnown(String csv) { + if (csv == null || csv.isBlank()) { + return false; + } + return java.util.Arrays.stream(csv.split(",")) + .map(String::trim) + .allMatch(ALLOWED::contains); + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java b/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java new file mode 100644 index 0000000..639c1fd --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/user/UserService.java @@ -0,0 +1,96 @@ +package com.syncflow.api.user; + +import com.syncflow.api.user.entity.UserEntity; +import com.syncflow.api.user.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Map; +import java.util.NoSuchElementException; +import java.util.UUID; + +/** + * User lifecycle operations. Centralizes create/update/role assignment so the + * controller stays a thin transport layer and the domain rules (role + * allow-list, + * password encoding, duplicate checks) are testable without HTTP. + */ +@Service +public class UserService { + + private final UserRepository repository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository repository, PasswordEncoder passwordEncoder) { + this.repository = repository; + this.passwordEncoder = passwordEncoder; + } + + public UserEntity create(String username, String password, String email, String roles) { + if (repository.existsByUsername(username)) { + throw new UserConflictException("username taken: " + username); + } + var now = Instant.now(); + var u = new UserEntity(); + u.setId(UUID.randomUUID().toString()); + u.setUsername(username); + u.setPasswordHash(passwordEncoder.encode(password)); + u.setEmail(email); + u.setRoles(roles == null || roles.isBlank() ? RoleConstants.USER : roles); + u.setEnabled(true); + u.setCreatedAt(now); + u.setUpdatedAt(now); + return repository.save(u); + } + + public UserEntity update(String id, String email, String roles, Boolean enabled) { + var u = find(id); + if (email != null) + u.setEmail(email); + if (roles != null) + u.setRoles(roles); + if (enabled != null) + u.setEnabled(enabled); + u.setUpdatedAt(Instant.now()); + return repository.save(u); + } + + public UserEntity setRoles(String id, String roles) { + var u = find(id); + u.setRoles(roles); + u.setUpdatedAt(Instant.now()); + return repository.save(u); + } + + public void delete(String id) { + repository.deleteById(id); + } + + public UserEntity find(String id) { + return repository.findById(id) + .orElseThrow(() -> new NoSuchElementException("User not found: " + id)); + } + + public List list() { + return repository.findAll(); + } + + public Map toMap(UserEntity u) { + return Map.of( + "id", u.getId(), + "username", u.getUsername(), + "email", u.getEmail() != null ? u.getEmail() : "", + "roles", u.getRoles(), + "enabled", u.isEnabled()); + } + + /** Thrown when creating a user whose username already exists. */ + public static class UserConflictException extends RuntimeException { + + public UserConflictException(String message) { + super(message); + } + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java b/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java new file mode 100644 index 0000000..4258e1a --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/user/entity/UserEntity.java @@ -0,0 +1,46 @@ +package com.syncflow.api.user.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.Setter; + +import java.time.Instant; + +@Setter +@Getter +@Entity +@Table(name = "app_users") +public class UserEntity { + + @Id + @Column(length = 36) + private String id; + + @Column(nullable = false, unique = true, length = 64) + private String username; + + @Column(name = "password_hash", nullable = false, length = 100) + private String passwordHash; + + @Column(length = 255) + private String email; + + /** Comma-separated role names, e.g. "ADMIN,USER". */ + @Column(nullable = false, length = 512) + private String roles; + + @Column(nullable = false) + private boolean enabled; + + @Column(name = "created_at", nullable = false) + private Instant createdAt; + + @Column(name = "updated_at", nullable = false) + private Instant updatedAt; + + public UserEntity() { + } +} diff --git a/syncflow-api/src/main/java/com/syncflow/api/user/repository/UserRepository.java b/syncflow-api/src/main/java/com/syncflow/api/user/repository/UserRepository.java new file mode 100644 index 0000000..dcd5e2d --- /dev/null +++ b/syncflow-api/src/main/java/com/syncflow/api/user/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.syncflow.api.user.repository; + +import com.syncflow.api.user.entity.UserEntity; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); + + boolean existsByUsername(String username); +} diff --git a/syncflow-api/src/main/resources/application.yml b/syncflow-api/src/main/resources/application.yml index 2c5e839..5ce4fe9 100644 --- a/syncflow-api/src/main/resources/application.yml +++ b/syncflow-api/src/main/resources/application.yml @@ -85,6 +85,11 @@ syncflow: encryption: # 16-byte AES key, base64-encoded. Replace in production with a secure key. key: MDEyMzQ1Njc4OWFiY2RlZg== + jwt: + # Base64-encoded HMAC secret (HS256). Replace in production with a 32+ byte key. + secret: ${SYNCFLOW_JWT_SECRET:c3luY2Zsb3ctaHMyNTYtand0LXNlY3JldC1rZXktMjAyNi1jaGFuZ2UtaW4tcHJvZA==} + issuer: ${SYNCFLOW_JWT_ISSUER:syncflow} + expiry-minutes: ${SYNCFLOW_JWT_EXPIRY_MINUTES:60} ai: endpoint: ${SYNCFLOW_AI_ENDPOINT:https://api.openai.com/v1/chat/completions} model: ${SYNCFLOW_AI_MODEL:gpt-4o} diff --git a/syncflow-api/src/main/resources/db/migration/V9__users.sql b/syncflow-api/src/main/resources/db/migration/V9__users.sql new file mode 100644 index 0000000..1f57aee --- /dev/null +++ b/syncflow-api/src/main/resources/db/migration/V9__users.sql @@ -0,0 +1,25 @@ +-- Application user accounts for JWT authentication and RBAC. +-- Named app_users to avoid colliding with sample/integration tables named "users". +CREATE TABLE IF NOT EXISTS app_users ( + id VARCHAR(36) PRIMARY KEY, + username VARCHAR(64) NOT NULL UNIQUE, + password_hash VARCHAR(100) NOT NULL, -- BCrypt (~60 chars) + email VARCHAR(255), + roles VARCHAR(512) DEFAULT 'USER', -- CSV of role names + enabled BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(), + updated_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() +); + +-- Default admin account (password: admin-test-password — for dev/test; replace in prod). +-- PolicyResolver grants the 'admin' username full permissions. +INSERT INTO app_users (id, username, password_hash, email, roles, enabled) +VALUES ( + '00000000-0000-0000-0000-000000000001', + 'admin', + '$2a$10$kcqbSa6/YwMoZge2NPc5b.ASDIr7vXvAjZ6Amvfdl.A6z.azwH1Au', -- BCrypt('admin-test-password') + 'admin@syncflow.local', + 'ADMIN', + TRUE +) +ON CONFLICT (username) DO NOTHING; \ No newline at end of file diff --git a/syncflow-api/src/test/java/com/syncflow/api/security/ApiAuthContractTest.java b/syncflow-api/src/test/java/com/syncflow/api/security/ApiAuthContractTest.java index aebf8ec..3885290 100644 --- a/syncflow-api/src/test/java/com/syncflow/api/security/ApiAuthContractTest.java +++ b/syncflow-api/src/test/java/com/syncflow/api/security/ApiAuthContractTest.java @@ -19,13 +19,18 @@ import static io.restassured.RestAssured.given; import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.equalTo; import static org.hamcrest.Matchers.is; +import static org.hamcrest.Matchers.notNullValue; /** - * Security-behavior tests that assert the REAL WebSecurityConfig (authenticated - * /api/** endpoints) rejects unauthenticated requests. Unlike the functional - * contract tests (which use a permissive test security via - * AbstractIntegrationTest), these intentionally load the production chain. + * Security-behavior tests that assert the REAL WebSecurityConfig (JWT bearer + * auth, + * authenticated /api/** endpoints). Unlike the functional contract tests (which + * use + * a permissive test security via AbstractIntegrationTest), these load the + * production + * chain. */ @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) @Testcontainers @@ -33,6 +38,10 @@ @EnabledIfSystemProperty(named = "tests.integration", matches = "true") class ApiAuthContractTest { + // Matches the seeded admin user in V9__users.sql and the base64 secret default. + private static final String ADMIN_USER = "admin"; + private static final String ADMIN_PASS = "admin-test-password"; + @Container static PostgreSQLContainer postgres = new PostgreSQLContainer<>("postgres:16-alpine") .withDatabaseName("syncflow") @@ -49,6 +58,9 @@ static void properties(DynamicPropertyRegistry registry) { registry.add("spring.datasource.password", postgres::getPassword); registry.add("spring.flyway.enabled", () -> "true"); registry.add("syncflow.encryption.key", () -> "MDEyMzQ1Njc4OWFiY2RlZg=="); + registry.add("syncflow.jwt.secret", + () -> "c3luY2Zsb3ctaHMyNTYtand0LXNlY3JldC1rZXktMjAyNi1jaGFuZ2UtaW4tcHJvZA=="); + registry.add("syncflow.jwt.issuer", () -> "syncflow"); } @BeforeEach @@ -56,6 +68,14 @@ void setUp() { RestAssured.port = port; } + private String loginToken() { + return given().contentType(ContentType.JSON) + .body(Map.of("username", ADMIN_USER, "password", ADMIN_PASS)) + .when().post("/api/auth/login") + .then().statusCode(200).body("token", notNullValue()) + .extract().path("token"); + } + @Test @DisplayName("POST /api/connections without auth -> 401/403") void createConnectionUnauthenticated() { @@ -66,9 +86,38 @@ void createConnectionUnauthenticated() { } @Test - @DisplayName("DELETE /api/connections without role -> 401/403") - void deleteWithoutPermission() { - given().when().delete("/api/connections/nonexistent") - .then().statusCode(anyOf(is(401), is(403), is(204), is(500))); + @DisplayName("POST /api/auth/login with seeded admin -> 200 + token") + void loginSuccess() { + given().contentType(ContentType.JSON) + .body(Map.of("username", ADMIN_USER, "password", ADMIN_PASS)) + .when().post("/api/auth/login") + .then().statusCode(200) + .body("token", notNullValue()) + .body("tokenType", equalTo("Bearer")); + } + + @Test + @DisplayName("POST /api/auth/login with wrong password -> 401") + void loginBadPassword() { + given().contentType(ContentType.JSON) + .body(Map.of("username", ADMIN_USER, "password", "wrong")) + .when().post("/api/auth/login") + .then().statusCode(401); + } + + @Test + @DisplayName("GET /api/users with valid token -> 200") + void usersAuthed() { + var token = loginToken(); + given().header("Authorization", "Bearer " + token) + .when().get("/api/users") + .then().statusCode(200) + .body("$", notNullValue()); + } + + @Test + @DisplayName("GET /api/users without token -> 401") + void usersUnauthenticated() { + given().when().get("/api/users").then().statusCode(401); } } diff --git a/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java b/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java new file mode 100644 index 0000000..af815a4 --- /dev/null +++ b/syncflow-api/src/test/java/com/syncflow/api/security/AuthServiceTest.java @@ -0,0 +1,81 @@ +package com.syncflow.api.security; + +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.JWKSet; +import com.nimbusds.jose.jwk.OctetSequenceKey; +import com.nimbusds.jose.jwk.source.ImmutableJWKSet; +import com.syncflow.api.config.JwtProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.oauth2.jwt.JwtEncoder; +import org.springframework.security.oauth2.jwt.NimbusJwtEncoder; + +import javax.crypto.spec.SecretKeySpec; +import java.util.Base64; +import java.util.List; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class AuthServiceTest { + + private static final String SECRET = "c3luY2Zsb3ctaHMyNTYtand0LXNlY3JldC1rZXktMjAyNi1jaGFuZ2UtaW4tcHJvZA=="; + + private AuthenticationManager authenticationManager; + private AuthService service; + + @BeforeEach + void setUp() { + authenticationManager = mock(AuthenticationManager.class); + var props = new JwtProperties(); + props.setSecret(SECRET); + props.setIssuer("syncflow"); + props.setExpiryMinutes(60); + var key = new SecretKeySpec(Base64.getDecoder().decode(SECRET), "HmacSHA256"); + var jwk = new OctetSequenceKey.Builder(key) + .algorithm(JWSAlgorithm.HS256) + .build(); + JwtEncoder encoder = new NimbusJwtEncoder(new ImmutableJWKSet<>(new JWKSet(jwk))); + service = new AuthService(authenticationManager, encoder, props); + } + + @Test + void loginIssuesJwtWithScopeClaim() { + var principal = User.withUsername("admin") + .password("pw") + .authorities("ROLE_ADMIN") + .build(); + when(authenticationManager.authenticate(any())) + .thenReturn(new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities())); + + var token = service.login("admin", "pw"); + assertNotNull(token); + var parts = token.split("\\."); + assertEquals(3, parts.length, "JWT should have three segments"); + var claims = new String(Base64.getUrlDecoder().decode(parts[1])); + assertTrue(claims.contains("syncflow"), "issuer should be present"); + assertTrue(claims.contains("ADMIN"), "scope claim should carry roles"); + } + + @Test + void scopesStripRolePrefix() { + var principal = User.withUsername("bob") + .password("pw") + .authorities(List.of(new SimpleGrantedAuthority("ROLE_USER"))) + .build(); + when(authenticationManager.authenticate(any())) + .thenReturn(new UsernamePasswordAuthenticationToken(principal, null, principal.getAuthorities())); + + var token = service.login("bob", "pw"); + var claims = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1])); + assertTrue(claims.contains("\"USER\""), "scope should be bare role, not ROLE_-prefixed"); + } +} diff --git a/syncflow-api/src/test/java/com/syncflow/api/security/DbUserDetailsServiceTest.java b/syncflow-api/src/test/java/com/syncflow/api/security/DbUserDetailsServiceTest.java new file mode 100644 index 0000000..ac4d4c1 --- /dev/null +++ b/syncflow-api/src/test/java/com/syncflow/api/security/DbUserDetailsServiceTest.java @@ -0,0 +1,61 @@ +package com.syncflow.api.security; + +import com.syncflow.api.user.entity.UserEntity; +import com.syncflow.api.user.repository.UserRepository; +import org.junit.jupiter.api.Test; + +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class DbUserDetailsServiceTest { + + private final UserRepository repository = mock(UserRepository.class); + private final DbUserDetailsService service = new DbUserDetailsService(repository); + + private UserEntity user(String username, String roles, boolean enabled) { + var u = new UserEntity(); + u.setUsername(username); + u.setPasswordHash("$2a$10$kcqbSa6/YwMoZge2NPc5b.ASDIr7vXvAjZ6Amvfdl.A6z.azwH1Au"); + u.setRoles(roles); + u.setEnabled(enabled); + return u; + } + + @Test + void mapsCsvRolesToAuthorities() { + when(repository.findByUsername("alice")).thenReturn(Optional.of(user("alice", "ADMIN,USER", true))); + var details = service.loadUserByUsername("alice"); + assertTrue(details.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_ADMIN"))); + assertTrue(details.getAuthorities().stream() + .anyMatch(a -> a.getAuthority().equals("ROLE_USER"))); + assertEquals(2, details.getAuthorities().size()); + } + + @Test + void blankRolesYieldUserAuthority() { + when(repository.findByUsername("bob")).thenReturn(Optional.of(user("bob", " ", true))); + var details = service.loadUserByUsername("bob"); + assertTrue(details.getAuthorities().isEmpty()); + } + + @Test + void disabledUserIsRejected() { + when(repository.findByUsername("locked")).thenReturn(Optional.of(user("locked", "USER", false))); + var details = service.loadUserByUsername("locked"); + assertFalse(details.isEnabled()); + } + + @Test + void unknownUserThrows() { + when(repository.findByUsername("nope")).thenReturn(Optional.empty()); + assertThrows(org.springframework.security.core.userdetails.UsernameNotFoundException.class, + () -> service.loadUserByUsername("nope")); + } +} diff --git a/syncflow-api/src/test/java/com/syncflow/api/user/UserEntityMappingTest.java b/syncflow-api/src/test/java/com/syncflow/api/user/UserEntityMappingTest.java new file mode 100644 index 0000000..ae93336 --- /dev/null +++ b/syncflow-api/src/test/java/com/syncflow/api/user/UserEntityMappingTest.java @@ -0,0 +1,24 @@ +package com.syncflow.api.user; + +import com.syncflow.api.user.entity.UserEntity; +import jakarta.persistence.Table; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * Regression: the auth user table is named {@code app_users}, not + * {@code users}, + * so it does not collide with sample/integration tables that use {@code users} + * (e.g. PgMongoSampleE2eTest). A collision caused "column full_name of relation + * users does not exist" in that test. + */ +class UserEntityMappingTest { + + @Test + void entityMapsToAppUsersTable() { + var table = UserEntity.class.getAnnotation(Table.class); + assertEquals("app_users", table.name(), + "auth users table must be app_users to avoid colliding with sample 'users' tables"); + } +} diff --git a/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java b/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java new file mode 100644 index 0000000..4931b09 --- /dev/null +++ b/syncflow-api/src/test/java/com/syncflow/api/user/UserServiceTest.java @@ -0,0 +1,62 @@ +package com.syncflow.api.user; + +import com.syncflow.api.user.entity.UserEntity; +import com.syncflow.api.user.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +class UserServiceTest { + + private UserRepository repository; + private UserService service; + private PasswordEncoder encoder; + + @BeforeEach + void setUp() { + repository = mock(UserRepository.class); + encoder = new BCryptPasswordEncoder(); + service = new UserService(repository, encoder); + } + + @Test + void createEncodesPasswordAndDefaultsRoles() { + when(repository.existsByUsername("alice")).thenReturn(false); + when(repository.save(org.mockito.ArgumentMatchers.any(UserEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + var u = service.create("alice", "pw", "alice@x.com", null); + + assertEquals("alice", u.getUsername()); + assertNotEquals("pw", u.getPasswordHash(), "password must be hashed"); + assertNotEquals("pw", encoder.encode("pw")); + assertEquals(RoleConstants.USER, u.getRoles(), "blank roles should default to USER"); + assertTrue(u.isEnabled()); + } + + @Test + void createKeepsProvidedRoles() { + when(repository.existsByUsername("bob")).thenReturn(false); + when(repository.save(org.mockito.ArgumentMatchers.any(UserEntity.class))) + .thenAnswer(inv -> inv.getArgument(0)); + var u = service.create("bob", "pw", null, "ADMIN,USER"); + assertEquals("ADMIN,USER", u.getRoles()); + } + + @Test + void createRejectsDuplicateUsername() { + when(repository.existsByUsername("dup")).thenReturn(true); + assertThrows(UserService.UserConflictException.class, () -> service.create("dup", "pw", null, null)); + verify(repository).existsByUsername(anyString()); + } +} diff --git a/syncflow-security/src/main/java/com/syncflow/security/SecurityConfig.java b/syncflow-security/src/main/java/com/syncflow/security/SecurityConfig.java index b93fe42..a01d82e 100644 --- a/syncflow-security/src/main/java/com/syncflow/security/SecurityConfig.java +++ b/syncflow-security/src/main/java/com/syncflow/security/SecurityConfig.java @@ -11,6 +11,7 @@ public final class SecurityConfig { private static final List PUBLIC_PATHS = List.of( "/api/health/**", + "/api/auth/**", "/actuator/**", "/v3/api-docs/**", "/swagger-ui/**",