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:
@@ -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) {}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE users ADD COLUMN password_hash TEXT;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user