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);
    }
}

UserPasswordRepository is not a JpaRepository — 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");
    }
}
  • ChangePasswordRequest has loginName, password, currentPassword fields.
  • ResetPasswordRequest has loginName, password fields.
  • Both delegate to PasswordMgmtServiceImpl which re-salts and writes via your UserPasswordRepository.

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 PasswordMgmtService with a @Primary bean 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 @Primary bean).
  • No authorities on the token — extend LocalDBAuthenticationProvider for role-based @PreAuthorize.
  • No password history / expiry / rotation.
  • No SSO / OAuth / OIDC / SAML — use Spring Security starters directly.
  • PasswordMgmtServiceImpl.verifyPassword(...) throws UnsupportedOperationException — go through PasswordVerificationService instead.