feat: add password support for local user creation and per-user login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-03-17 19:08:19 +01:00
parent 653ef958ed
commit 6f5b5b8655
7 changed files with 71 additions and 30 deletions

View File

@@ -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) {}
}

View File

@@ -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<String> 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<String> roles = rbacService.getSystemRoleNames(subject);
if (roles.isEmpty()) {
roles = List.of("ADMIN");
roles = List.of("VIEWER");
}
String accessToken = jwtService.createAccessToken(subject, "user", roles);

View File

@@ -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<String> getPasswordHash(String userId) {
List<String> 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;

View File

@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN password_hash TEXT;

View File

@@ -15,4 +15,8 @@ public interface UserRepository {
void upsert(UserInfo user);
void delete(String userId);
void setPassword(String userId, String passwordHash);
Optional<String> getPasswordHash(String userId);
}

View File

@@ -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<UserDetail>('/users', {
method: 'POST',
body: JSON.stringify(data),

View File

@@ -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)" />
</div>
<div className={styles.createFormRow}>
<label className={styles.createFormLabel}>Password</label>
<input className={styles.createFormInput} type="password" value={newPassword}
onChange={e => setNewPassword(e.target.value)}
placeholder="Password (required for local login)" />
</div>
{createError && <div className={styles.createFormError}>{createError}</div>}
<div className={styles.createFormActions}>
<button type="button" className={styles.createFormBtn}
onClick={() => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setCreateError(''); }}>Cancel</button>
onClick={() => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); }}>Cancel</button>
<button type="button" className={styles.createFormBtnPrimary}
disabled={!newUsername.trim() || createUser.isPending}
onClick={() => {
@@ -121,8 +128,9 @@ export function UsersTab() {
username: newUsername.trim(),
displayName: newDisplayName.trim() || undefined,
email: newEmail.trim() || undefined,
password: newPassword || undefined,
}, {
onSuccess: () => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setCreateError(''); },
onSuccess: () => { setShowCreateForm(false); setNewUsername(''); setNewDisplayName(''); setNewEmail(''); setNewPassword(''); setCreateError(''); },
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create user'),
});
}}>Create</button>