User Management — integration guide#
The per-interface pages in this section are API reference. This page is the how-do-I-actually-wire-it-up walkthrough — the four steps to go from “dependency added” to “users log in, change their passwords, and get locked out after repeated failures.”
Step 1 — add the dependency#
implementation 'com.palmyralabs.palmyra.extn:palmyra-dbpwd-mgmt:1.4.4'Maven group .extn; Java package com.palmyralabs.palmyra.ext.usermgmt. The extension has no auto-configuration metadata — if com.palmyralabs.palmyra.ext.usermgmt is outside the app’s scan root, add it explicitly:
@SpringBootApplication(
scanBasePackages = {"com.example.app", "com.palmyralabs.palmyra.ext.usermgmt"})Step 2 — implement UserPasswordRepository#
The extension ships no default — the consumer adapts the repository to its own user table shape.
@Entity
@Table(name = "app_user")
@Getter @Setter
public class AppUserEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id;
@Column(name = "login_name", unique = true, nullable = false) private String loginName;
@Column(name = "random") private String random;
@Column(name = "salt") private String salt;
@Column(name = "lock_expire") private LocalDateTime lockExpire;
}
public interface AppUserJpaRepo extends JpaRepository<AppUserEntity, Integer> {
AppUserEntity findByLoginName(String loginName);
}
@Repository
@RequiredArgsConstructor
public class UserPasswordRepositoryImpl implements UserPasswordRepository {
private final AppUserJpaRepo jpa;
@Override
public UserPasswordModel findByLoginName(String loginName) {
AppUserEntity e = jpa.findByLoginName(loginName);
if (e == null) return null;
UserPasswordModel m = new UserPasswordModel();
m.setId(e.getId());
m.setLoginName(e.getLoginName());
m.setRandom(e.getRandom());
m.setSalt(e.getSalt());
m.setLockExpire(e.getLockExpire());
return m;
}
@Override
public void update(UserPasswordModel m) {
AppUserEntity e = jpa.findByLoginName(m.getLoginName());
e.setRandom(m.getRandom());
e.setSalt(m.getSalt());
e.setLockExpire(m.getLockExpire());
jpa.save(e);
}
}
UserPasswordRepositoryis not aJpaRepository— keep it a plain interface. The extension uses the result for password verification only; it doesn’t know about your@Entity.
Step 3 — wire LocalDBAuthenticationProvider into your SecurityFilterChain#
@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final LocalDBAuthenticationProvider localDbProvider;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authenticationProvider(localDbProvider)
.csrf(c -> c.disable())
.authorizeHttpRequests(a -> a
.requestMatchers("/auth/login", "/public/**").permitAll()
.anyRequest().authenticated())
.httpBasic(Customizer.withDefaults())
.build();
}
}Every request now authenticates against your app_user table via the salted-MD5 compare. For session-based login (JSESSIONID cookie) replace .httpBasic(...) with a custom /auth/login controller and a session-backed SecurityContextRepository — see the Security Config recipe in the broader Palmyra docs.
Step 4 — expose REST endpoints for change / reset password#
The extension publishes no controllers. Add a thin @RestController that delegates to the PasswordMgmtService:
@RestController
@RequestMapping("/auth")
@RequiredArgsConstructor
public class AuthController {
private final PasswordMgmtService pwdService;
@PostMapping("/change-password")
public ResponseEntity<Void> changePassword(@RequestBody ChangePasswordRequest req,
Authentication auth) {
// Narrow the loginName to the current session principal — don't trust the body.
req.setLoginName(auth.getName());
if (pwdService.changePassword(req)) return ResponseEntity.ok().build();
throw new UnAuthorizedException("PWD001", "Current password invalid");
}
@PostMapping("/admin/reset-password")
@PreAuthorize("hasPermission('XpmUser', 'Unlock')")
public ResponseEntity<Void> resetPassword(@RequestBody ResetPasswordRequest req) {
if (pwdService.resetPassword(req)) return ResponseEntity.ok().build();
throw new UnAuthorizedException("PWD002", "Reset failed");
}
}ChangePasswordRequesthasloginName,password,currentPasswordfields.ResetPasswordRequesthasloginName,passwordfields.- Both delegate to
PasswordMgmtServiceImplwhich re-salts and writes via yourUserPasswordRepository.
Step 5 (optional) — activate account lockout#
Default is a no-op (NoopUserLockingService). Override with a @Primary bean:
@Service
@Primary
@RequiredArgsConstructor
public class DbUserLockingService implements UserLockingService {
private static final int MAX_FAILURES = 5;
private static final Duration LOCK_DURATION = Duration.ofMinutes(15);
private final UserPasswordRepository repo;
private final LoginAttemptDao attemptDao;
@Override
public boolean isLocked(UserPasswordModel m) {
return m.getLockExpire() != null && m.getLockExpire().isAfter(LocalDateTime.now());
}
@Override
public void markLoginFailure(UserPasswordModel m) {
if (attemptDao.incrementFailures(m.getId()) >= MAX_FAILURES) {
m.setLockExpire(LocalDateTime.now().plus(LOCK_DURATION));
repo.update(m);
}
}
@Override
public void markLoginSuccessfull(UserPasswordModel m) {
attemptDao.reset(m.getId());
if (m.getLockExpire() != null) { m.setLockExpire(null); repo.update(m); }
}
}Password format#
Stored hash: salt = MD5_hex(random + password), where random is a 128-char SecureRandom string. When random IS NULL, PasswordMgmtServiceImpl.isValid short-circuits to a plain salt.equals(password) compare — useful for seeding a bootstrap admin without reproducing the salt math.
MD5 is weak. Override
PasswordMgmtServicewith a@Primarybean for BCrypt / Argon2 before production.
Password policy#
Regex: ^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]{8,}$
Min 8 chars, mixed case, one digit, one of @$!%*?&. Enforced in resetPassword / changePassword. To relax or tighten, override the validatePassword logic in a @Primary PasswordMgmtService.
Extension points#
| Concern | How to override |
|---|---|
| Hash algorithm | @Primary PasswordMgmtService bean |
| Password policy | same — replace validatePassword |
| Lockout | @Primary UserLockingService |
| Auth flow | implement PasswordVerificationService + custom AuthenticationProvider |
| Claims / authorities | override LocalDBAuthenticationProvider.authenticate() (default returns empty authorities) |
Known gaps#
- MD5 hashing (override with BCrypt/Argon2 via
@Primarybean). - No authorities on the token — extend
LocalDBAuthenticationProviderfor role-based@PreAuthorize. - No password history / expiry / rotation.
- No SSO / OAuth / OIDC / SAML — use Spring Security starters directly.
PasswordMgmtServiceImpl.verifyPassword(...)throwsUnsupportedOperationException— go throughPasswordVerificationServiceinstead.