From 6f5b5b86552db28d3fc5051aad5128fd1da3d063 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Tue, 17 Mar 2026 19:08:19 +0100 Subject: [PATCH] feat: add password support for local user creation and per-user login Co-Authored-By: Claude Opus 4.6 (1M context) --- .../app/controller/UserAdminController.java | 9 ++- .../server/app/security/UiAuthController.java | 58 ++++++++++--------- .../app/storage/PostgresUserRepository.java | 15 +++++ .../db/migration/V3__user_password.sql | 1 + .../server/core/security/UserRepository.java | 4 ++ ui/src/api/queries/admin/rbac.ts | 2 +- ui/src/pages/admin/rbac/UsersTab.tsx | 12 +++- 7 files changed, 71 insertions(+), 30 deletions(-) create mode 100644 cameleer3-server-app/src/main/resources/db/migration/V3__user_password.sql diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java index 0cb9abe5..766484ba 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/controller/UserAdminController.java @@ -23,6 +23,8 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + import java.time.Instant; import java.util.List; import java.util.Map; @@ -38,6 +40,8 @@ import java.util.UUID; @PreAuthorize("hasRole('ADMIN')") public class UserAdminController { + private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); + private final RbacService rbacService; private final UserRepository userRepository; private final AuditService auditService; @@ -79,6 +83,9 @@ public class UserAdminController { request.displayName() != null ? request.displayName() : request.username(), Instant.now()); userRepository.upsert(user); + if (request.password() != null && !request.password().isBlank()) { + userRepository.setPassword(userId, passwordEncoder.encode(request.password())); + } rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID); auditService.log("create_user", AuditCategory.USER_MGMT, userId, Map.of("username", request.username()), AuditResult.SUCCESS, httpRequest); @@ -165,6 +172,6 @@ public class UserAdminController { return ResponseEntity.noContent().build(); } - public record CreateUserRequest(String username, String displayName, String email) {} + public record CreateUserRequest(String username, String displayName, String email, String password) {} public record UpdateUserRequest(String displayName, String email) {} } diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java index 6002ae47..f3c85998 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/security/UiAuthController.java @@ -21,6 +21,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -30,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException; import java.time.Instant; import java.util.List; import java.util.Map; +import java.util.Optional; /** * Authentication endpoints for the UI (local credentials). @@ -44,6 +46,7 @@ import java.util.Map; public class UiAuthController { private static final Logger log = LoggerFactory.getLogger(UiAuthController.class); + private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(); private final JwtService jwtService; private final SecurityProperties properties; @@ -70,38 +73,41 @@ public class UiAuthController { HttpServletRequest httpRequest) { String configuredUser = properties.getUiUser(); String configuredPassword = properties.getUiPassword(); - - if (configuredUser == null || configuredUser.isBlank() - || configuredPassword == null || configuredPassword.isBlank()) { - log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured"); - auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, - Map.of("reason", "UI authentication not configured"), AuditResult.FAILURE, httpRequest); - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured"); - } - - if (!configuredUser.equals(request.username()) - || !configuredPassword.equals(request.password())) { - log.debug("UI login failed for user: {}", request.username()); - auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, - Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest); - throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"); - } - String subject = "user:" + request.username(); - // Upsert local user into store (without roles — roles are in user_roles table) - try { - userRepository.upsert(new UserInfo( - subject, "local", "", request.username(), Instant.now())); - rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID); - rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID); - } catch (Exception e) { - log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage()); + // Try env-var admin first + boolean envMatch = configuredUser != null && !configuredUser.isBlank() + && configuredPassword != null && !configuredPassword.isBlank() + && configuredUser.equals(request.username()) + && configuredPassword.equals(request.password()); + + if (!envMatch) { + // Try per-user password + Optional hash = userRepository.getPasswordHash(subject); + if (hash.isEmpty() || !passwordEncoder.matches(request.password(), hash.get())) { + log.debug("UI login failed for user: {}", request.username()); + auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null, + Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest); + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials"); + } } + if (envMatch) { + // Env-var admin: upsert and ensure ADMIN role + Admins group + try { + userRepository.upsert(new UserInfo( + subject, "local", "", request.username(), Instant.now())); + rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID); + rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID); + } catch (Exception e) { + log.warn("Failed to upsert local admin to store (login continues): {}", e.getMessage()); + } + } + // Per-user logins: user already exists in DB (created by admin) + List roles = rbacService.getSystemRoleNames(subject); if (roles.isEmpty()) { - roles = List.of("ADMIN"); + roles = List.of("VIEWER"); } String accessToken = jwtService.createAccessToken(subject, "user", roles); diff --git a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java index 64d77f5b..c924fb79 100644 --- a/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java +++ b/cameleer3-server-app/src/main/java/com/cameleer3/server/app/storage/PostgresUserRepository.java @@ -49,6 +49,21 @@ public class PostgresUserRepository implements UserRepository { jdbc.update("DELETE FROM users WHERE user_id = ?", userId); } + @Override + public void setPassword(String userId, String passwordHash) { + jdbc.update("UPDATE users SET password_hash = ? WHERE user_id = ?", passwordHash, userId); + } + + @Override + public Optional getPasswordHash(String userId) { + List results = jdbc.query( + "SELECT password_hash FROM users WHERE user_id = ?", + (rs, rowNum) -> rs.getString("password_hash"), + userId); + if (results.isEmpty() || results.get(0) == null) return Optional.empty(); + return Optional.of(results.get(0)); + } + private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException { java.sql.Timestamp ts = rs.getTimestamp("created_at"); java.time.Instant createdAt = ts != null ? ts.toInstant() : null; diff --git a/cameleer3-server-app/src/main/resources/db/migration/V3__user_password.sql b/cameleer3-server-app/src/main/resources/db/migration/V3__user_password.sql new file mode 100644 index 00000000..c64a6010 --- /dev/null +++ b/cameleer3-server-app/src/main/resources/db/migration/V3__user_password.sql @@ -0,0 +1 @@ +ALTER TABLE users ADD COLUMN password_hash TEXT; diff --git a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java index b3b128fc..4f57dad6 100644 --- a/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java +++ b/cameleer3-server-core/src/main/java/com/cameleer3/server/core/security/UserRepository.java @@ -15,4 +15,8 @@ public interface UserRepository { void upsert(UserInfo user); void delete(String userId); + + void setPassword(String userId, String passwordHash); + + Optional getPasswordHash(String userId); } diff --git a/ui/src/api/queries/admin/rbac.ts b/ui/src/api/queries/admin/rbac.ts index 773db41a..107e532b 100644 --- a/ui/src/api/queries/admin/rbac.ts +++ b/ui/src/api/queries/admin/rbac.ts @@ -267,7 +267,7 @@ export function useDeleteRole() { export function useCreateUser() { const qc = useQueryClient(); return useMutation({ - mutationFn: (data: { username: string; displayName?: string; email?: string }) => + mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) => adminFetch('/users', { method: 'POST', body: JSON.stringify(data), diff --git a/ui/src/pages/admin/rbac/UsersTab.tsx b/ui/src/pages/admin/rbac/UsersTab.tsx index e0008fe1..792c1344 100644 --- a/ui/src/pages/admin/rbac/UsersTab.tsx +++ b/ui/src/pages/admin/rbac/UsersTab.tsx @@ -37,6 +37,7 @@ export function UsersTab() { const [newUsername, setNewUsername] = useState(''); const [newDisplayName, setNewDisplayName] = useState(''); const [newEmail, setNewEmail] = useState(''); + const [newPassword, setNewPassword] = useState(''); const [createError, setCreateError] = useState(''); const createUser = useCreateUser(); @@ -110,10 +111,16 @@ export function UsersTab() { onChange={e => setNewEmail(e.target.value)} placeholder="Email (optional)" /> +
+ + setNewPassword(e.target.value)} + placeholder="Password (required for local login)" /> +
{createError &&
{createError}
}
+ onClick={() => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); }}>Cancel