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.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
|
||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@@ -38,6 +40,8 @@ import java.util.UUID;
|
|||||||
@PreAuthorize("hasRole('ADMIN')")
|
@PreAuthorize("hasRole('ADMIN')")
|
||||||
public class UserAdminController {
|
public class UserAdminController {
|
||||||
|
|
||||||
|
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
private final RbacService rbacService;
|
private final RbacService rbacService;
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
@@ -79,6 +83,9 @@ public class UserAdminController {
|
|||||||
request.displayName() != null ? request.displayName() : request.username(),
|
request.displayName() != null ? request.displayName() : request.username(),
|
||||||
Instant.now());
|
Instant.now());
|
||||||
userRepository.upsert(user);
|
userRepository.upsert(user);
|
||||||
|
if (request.password() != null && !request.password().isBlank()) {
|
||||||
|
userRepository.setPassword(userId, passwordEncoder.encode(request.password()));
|
||||||
|
}
|
||||||
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
|
rbacService.assignRoleToUser(userId, SystemRole.VIEWER_ID);
|
||||||
auditService.log("create_user", AuditCategory.USER_MGMT, userId,
|
auditService.log("create_user", AuditCategory.USER_MGMT, userId,
|
||||||
Map.of("username", request.username()), AuditResult.SUCCESS, httpRequest);
|
Map.of("username", request.username()), AuditResult.SUCCESS, httpRequest);
|
||||||
@@ -165,6 +172,6 @@ public class UserAdminController {
|
|||||||
return ResponseEntity.noContent().build();
|
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) {}
|
public record UpdateUserRequest(String displayName, String email) {}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import org.slf4j.Logger;
|
|||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
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.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
@@ -30,6 +31,7 @@ import org.springframework.web.server.ResponseStatusException;
|
|||||||
import java.time.Instant;
|
import java.time.Instant;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication endpoints for the UI (local credentials).
|
* Authentication endpoints for the UI (local credentials).
|
||||||
@@ -44,6 +46,7 @@ import java.util.Map;
|
|||||||
public class UiAuthController {
|
public class UiAuthController {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
||||||
|
private static final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder();
|
||||||
|
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final SecurityProperties properties;
|
private final SecurityProperties properties;
|
||||||
@@ -70,38 +73,41 @@ public class UiAuthController {
|
|||||||
HttpServletRequest httpRequest) {
|
HttpServletRequest httpRequest) {
|
||||||
String configuredUser = properties.getUiUser();
|
String configuredUser = properties.getUiUser();
|
||||||
String configuredPassword = properties.getUiPassword();
|
String configuredPassword = properties.getUiPassword();
|
||||||
|
String subject = "user:" + request.username();
|
||||||
|
|
||||||
if (configuredUser == null || configuredUser.isBlank()
|
// Try env-var admin first
|
||||||
|| configuredPassword == null || configuredPassword.isBlank()) {
|
boolean envMatch = configuredUser != null && !configuredUser.isBlank()
|
||||||
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
|
&& configuredPassword != null && !configuredPassword.isBlank()
|
||||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
&& configuredUser.equals(request.username())
|
||||||
Map.of("reason", "UI authentication not configured"), AuditResult.FAILURE, httpRequest);
|
&& configuredPassword.equals(request.password());
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!configuredUser.equals(request.username())
|
if (!envMatch) {
|
||||||
|| !configuredPassword.equals(request.password())) {
|
// 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());
|
log.debug("UI login failed for user: {}", request.username());
|
||||||
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
auditService.log(request.username(), "login_failed", AuditCategory.AUTH, null,
|
||||||
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
Map.of("reason", "Invalid credentials"), AuditResult.FAILURE, httpRequest);
|
||||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String subject = "user:" + request.username();
|
if (envMatch) {
|
||||||
|
// Env-var admin: upsert and ensure ADMIN role + Admins group
|
||||||
// Upsert local user into store (without roles — roles are in user_roles table)
|
|
||||||
try {
|
try {
|
||||||
userRepository.upsert(new UserInfo(
|
userRepository.upsert(new UserInfo(
|
||||||
subject, "local", "", request.username(), Instant.now()));
|
subject, "local", "", request.username(), Instant.now()));
|
||||||
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
|
rbacService.assignRoleToUser(subject, SystemRole.ADMIN_ID);
|
||||||
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
rbacService.addUserToGroup(subject, SystemRole.ADMINS_GROUP_ID);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to upsert local user to store (login continues): {}", e.getMessage());
|
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);
|
List<String> roles = rbacService.getSystemRoleNames(subject);
|
||||||
if (roles.isEmpty()) {
|
if (roles.isEmpty()) {
|
||||||
roles = List.of("ADMIN");
|
roles = List.of("VIEWER");
|
||||||
}
|
}
|
||||||
|
|
||||||
String accessToken = jwtService.createAccessToken(subject, "user", roles);
|
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);
|
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 {
|
private UserInfo mapUser(java.sql.ResultSet rs) throws java.sql.SQLException {
|
||||||
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
java.sql.Timestamp ts = rs.getTimestamp("created_at");
|
||||||
java.time.Instant createdAt = ts != null ? ts.toInstant() : null;
|
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 upsert(UserInfo user);
|
||||||
|
|
||||||
void delete(String userId);
|
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() {
|
export function useCreateUser() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: (data: { username: string; displayName?: string; email?: string }) =>
|
mutationFn: (data: { username: string; displayName?: string; email?: string; password?: string }) =>
|
||||||
adminFetch<UserDetail>('/users', {
|
adminFetch<UserDetail>('/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify(data),
|
body: JSON.stringify(data),
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export function UsersTab() {
|
|||||||
const [newUsername, setNewUsername] = useState('');
|
const [newUsername, setNewUsername] = useState('');
|
||||||
const [newDisplayName, setNewDisplayName] = useState('');
|
const [newDisplayName, setNewDisplayName] = useState('');
|
||||||
const [newEmail, setNewEmail] = useState('');
|
const [newEmail, setNewEmail] = useState('');
|
||||||
|
const [newPassword, setNewPassword] = useState('');
|
||||||
const [createError, setCreateError] = useState('');
|
const [createError, setCreateError] = useState('');
|
||||||
const createUser = useCreateUser();
|
const createUser = useCreateUser();
|
||||||
|
|
||||||
@@ -110,10 +111,16 @@ export function UsersTab() {
|
|||||||
onChange={e => setNewEmail(e.target.value)}
|
onChange={e => setNewEmail(e.target.value)}
|
||||||
placeholder="Email (optional)" />
|
placeholder="Email (optional)" />
|
||||||
</div>
|
</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>}
|
{createError && <div className={styles.createFormError}>{createError}</div>}
|
||||||
<div className={styles.createFormActions}>
|
<div className={styles.createFormActions}>
|
||||||
<button type="button" className={styles.createFormBtn}
|
<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}
|
<button type="button" className={styles.createFormBtnPrimary}
|
||||||
disabled={!newUsername.trim() || createUser.isPending}
|
disabled={!newUsername.trim() || createUser.isPending}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
@@ -121,8 +128,9 @@ export function UsersTab() {
|
|||||||
username: newUsername.trim(),
|
username: newUsername.trim(),
|
||||||
displayName: newDisplayName.trim() || undefined,
|
displayName: newDisplayName.trim() || undefined,
|
||||||
email: newEmail.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'),
|
onError: (err) => setCreateError(err instanceof Error ? err.message : 'Failed to create user'),
|
||||||
});
|
});
|
||||||
}}>Create</button>
|
}}>Create</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user