Add user authentication and management features
- Introduce user authentication with form-based login, including a default admin user. - Implement UserEntity, UserRepository, and UserService for user management. - Create RESTful endpoints for user listing and creation, restricted to admin role. - Enhance OpenAPI specification to document new authentication and user management endpoints. - Add frontend components for login and user management, including protected routes. - Implement context and hooks for managing authentication state in the React application. - Include unit tests for user service and authentication logic.
This commit is contained in:
parent
4c1584ec27
commit
2c61ab5fc9
@ -11,6 +11,15 @@ Minimal Quarkus (Java 21) project scaffold for the RolePlay service.
|
|||||||
- `src/main/java/de/neitzel/roleplay/fascade`: external facades
|
- `src/main/java/de/neitzel/roleplay/fascade`: external facades
|
||||||
- `src/main/resources/db/migration`: Liquibase changelog location
|
- `src/main/resources/db/migration`: Liquibase changelog location
|
||||||
|
|
||||||
|
## Login
|
||||||
|
|
||||||
|
The app requires authentication. A default admin user is seeded on first run:
|
||||||
|
|
||||||
|
- **Username:** `admin`
|
||||||
|
- **Password:** `changeme`
|
||||||
|
|
||||||
|
Change the password after first login (e.g. by creating a new admin user and retiring the default, or via a future password-change feature). Only users with the `admin` role can create new users (POST `/api/v1/admin/users`).
|
||||||
|
|
||||||
## Build and test
|
## Build and test
|
||||||
|
|
||||||
```zsh
|
```zsh
|
||||||
|
|||||||
14
pom.xml
14
pom.xml
@ -74,6 +74,10 @@
|
|||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
<artifactId>quarkus-hibernate-orm-panache</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-security-jpa</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>io.quarkus</groupId>
|
<groupId>io.quarkus</groupId>
|
||||||
<artifactId>quarkus-rest-client-config</artifactId>
|
<artifactId>quarkus-rest-client-config</artifactId>
|
||||||
@ -101,6 +105,16 @@
|
|||||||
<version>${mockito.version}</version>
|
<version>${mockito.version}</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.quarkus</groupId>
|
||||||
|
<artifactId>quarkus-junit5</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>io.rest-assured</groupId>
|
||||||
|
<artifactId>rest-assured</artifactId>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
</dependencies>
|
</dependencies>
|
||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|||||||
36
src/main/java/de/neitzel/roleplay/LoginDebugStartup.java
Normal file
36
src/main/java/de/neitzel/roleplay/LoginDebugStartup.java
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
package de.neitzel.roleplay;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import io.quarkus.runtime.StartupEvent;
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.enterprise.event.Observes;
|
||||||
|
import org.jboss.logging.Logger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs login-related debug info at startup: whether the seeded admin user exists
|
||||||
|
* and whether the stored password hash matches "changeme". Helps diagnose login failures.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class LoginDebugStartup {
|
||||||
|
|
||||||
|
private static final Logger LOG = Logger.getLogger(LoginDebugStartup.class);
|
||||||
|
|
||||||
|
private static final String DEFAULT_ADMIN_PASSWORD = "changeme";
|
||||||
|
|
||||||
|
void onStart(@Observes StartupEvent event) {
|
||||||
|
UserEntity admin = (UserEntity) UserEntity.find("username", "admin").firstResult();
|
||||||
|
if (admin == null) {
|
||||||
|
LOG.warn("Login debug: No user 'admin' found in database. Create the admin user (e.g. via Liquibase seed).");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
String hash = admin.getPassword();
|
||||||
|
boolean passwordMatches = hash != null && BcryptUtil.matches(DEFAULT_ADMIN_PASSWORD, hash);
|
||||||
|
if (passwordMatches) {
|
||||||
|
LOG.info("Login debug: User 'admin' exists; password matches '" + DEFAULT_ADMIN_PASSWORD + "'.");
|
||||||
|
} else {
|
||||||
|
LOG.warn("Login debug: User 'admin' exists but stored password hash does NOT match '" + DEFAULT_ADMIN_PASSWORD + "'. "
|
||||||
|
+ "Reset the password or update the seed hash in v002__users_and_roles.xml.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/main/java/de/neitzel/roleplay/business/UserService.java
Normal file
71
src/main/java/de/neitzel/roleplay/business/UserService.java
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
|
import de.neitzel.roleplay.data.UserRepository;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Business service for application users. Used by admin-only user management endpoints.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class UserService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public UserService(final UserRepository userRepository) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all users as summaries (no password).
|
||||||
|
*
|
||||||
|
* @return list of user summaries
|
||||||
|
*/
|
||||||
|
public List<UserSummary> listUsers() {
|
||||||
|
return userRepository.listAll().stream()
|
||||||
|
.map(UserService::toSummary)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user with the given username, plain password (hashed with bcrypt), and role.
|
||||||
|
*
|
||||||
|
* @param request create request (username, password, role)
|
||||||
|
* @return the created user summary
|
||||||
|
* @throws IllegalArgumentException if username is blank, password is blank, role is invalid, or username already exists
|
||||||
|
*/
|
||||||
|
public UserSummary createUser(final CreateUserRequest request) {
|
||||||
|
String username = request.getUsername();
|
||||||
|
String password = request.getPassword();
|
||||||
|
String role = request.getRole();
|
||||||
|
if (username == null || username.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Username is required");
|
||||||
|
}
|
||||||
|
if (password == null || password.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Password is required");
|
||||||
|
}
|
||||||
|
if (role == null || role.isBlank()) {
|
||||||
|
throw new IllegalArgumentException("Role is required");
|
||||||
|
}
|
||||||
|
if (!"admin".equals(role) && !"user".equals(role)) {
|
||||||
|
throw new IllegalArgumentException("Role must be 'admin' or 'user'");
|
||||||
|
}
|
||||||
|
if (userRepository.findByUsername(username) != null) {
|
||||||
|
throw new IllegalArgumentException("Username already exists: " + username);
|
||||||
|
}
|
||||||
|
UserEntity entity = UserEntity.add(username, password, role);
|
||||||
|
return toSummary(entity);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static UserSummary toSummary(final UserEntity entity) {
|
||||||
|
return new UserSummary(entity.getId(), entity.getUsername(), entity.getRole());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Request body for creating a new user (admin-only).
|
||||||
|
*/
|
||||||
|
public final class CreateUserRequest {
|
||||||
|
|
||||||
|
private String username;
|
||||||
|
private String password;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public CreateUserRequest() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a request with the given fields.
|
||||||
|
*
|
||||||
|
* @param username login name
|
||||||
|
* @param password plain-text password (will be hashed)
|
||||||
|
* @param role role (e.g. admin, user)
|
||||||
|
*/
|
||||||
|
public CreateUserRequest(final String username, final String password, final String role) {
|
||||||
|
this.username = username;
|
||||||
|
this.password = password;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
56
src/main/java/de/neitzel/roleplay/common/UserSummary.java
Normal file
56
src/main/java/de/neitzel/roleplay/common/UserSummary.java
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
package de.neitzel.roleplay.common;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for listing users (id, username, role). Does not expose password.
|
||||||
|
*/
|
||||||
|
public final class UserSummary {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String username;
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JSON.
|
||||||
|
*/
|
||||||
|
public UserSummary() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a summary for a user.
|
||||||
|
*
|
||||||
|
* @param id user id
|
||||||
|
* @param username login name
|
||||||
|
* @param role role (e.g. admin, user)
|
||||||
|
*/
|
||||||
|
public UserSummary(final UUID id, final String username, final String role) {
|
||||||
|
this.id = id;
|
||||||
|
this.username = username;
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
}
|
||||||
121
src/main/java/de/neitzel/roleplay/data/UserEntity.java
Normal file
121
src/main/java/de/neitzel/roleplay/data/UserEntity.java
Normal file
@ -0,0 +1,121 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheEntityBase;
|
||||||
|
import io.quarkus.security.jpa.Password;
|
||||||
|
import io.quarkus.security.jpa.Roles;
|
||||||
|
import io.quarkus.security.jpa.UserDefinition;
|
||||||
|
import io.quarkus.security.jpa.Username;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JPA entity for an application user stored in {@code rp_user}.
|
||||||
|
* Used by Quarkus Security JPA for form-based authentication (username/password, roles).
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "rp_user")
|
||||||
|
@UserDefinition
|
||||||
|
public class UserEntity extends PanacheEntityBase {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@Column(name = "id", length = 36, nullable = false, updatable = false)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
@Username
|
||||||
|
@Column(name = "username", nullable = false, unique = true, length = 255)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
@Password
|
||||||
|
@Column(name = "password", nullable = false, length = 255)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Roles
|
||||||
|
@Column(name = "role", nullable = false, length = 50)
|
||||||
|
private String role;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Default constructor for JPA.
|
||||||
|
*/
|
||||||
|
public UserEntity() {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the unique identifier of this user.
|
||||||
|
*/
|
||||||
|
public UUID getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the unique identifier of this user.
|
||||||
|
*/
|
||||||
|
public void setId(final UUID id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the login name of this user.
|
||||||
|
*/
|
||||||
|
public String getUsername() {
|
||||||
|
return username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the login name of this user.
|
||||||
|
*/
|
||||||
|
public void setUsername(final String username) {
|
||||||
|
this.username = username;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the bcrypt-hashed password of this user.
|
||||||
|
*/
|
||||||
|
public String getPassword() {
|
||||||
|
return password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the password (should be bcrypt-hashed, e.g. via {@link BcryptUtil#bcryptHash(String)}).
|
||||||
|
*/
|
||||||
|
public void setPassword(final String password) {
|
||||||
|
this.password = password;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the single role of this user (e.g. {@code admin} or {@code user}).
|
||||||
|
*/
|
||||||
|
public String getRole() {
|
||||||
|
return role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the role of this user.
|
||||||
|
*/
|
||||||
|
public void setRole(final String role) {
|
||||||
|
this.role = role;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user with the given username, plain password (hashed with bcrypt), and role.
|
||||||
|
*
|
||||||
|
* @param username login name
|
||||||
|
* @param plainPassword plain-text password (will be hashed)
|
||||||
|
* @param role role name (e.g. admin, user)
|
||||||
|
* @return the persisted entity
|
||||||
|
*/
|
||||||
|
public static UserEntity add(final String username, final String plainPassword, final String role) {
|
||||||
|
final UserEntity user = new UserEntity();
|
||||||
|
user.setId(UUID.randomUUID());
|
||||||
|
user.setUsername(username);
|
||||||
|
user.setPassword(BcryptUtil.bcryptHash(plainPassword));
|
||||||
|
user.setRole(role);
|
||||||
|
user.persist();
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
src/main/java/de/neitzel/roleplay/data/UserRepository.java
Normal file
32
src/main/java/de/neitzel/roleplay/data/UserRepository.java
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
package de.neitzel.roleplay.data;
|
||||||
|
|
||||||
|
import io.quarkus.hibernate.orm.panache.PanacheRepository;
|
||||||
|
|
||||||
|
import jakarta.enterprise.context.ApplicationScoped;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panache repository for {@link UserEntity}. Used for admin user listing and lookup by username.
|
||||||
|
*/
|
||||||
|
@ApplicationScoped
|
||||||
|
public class UserRepository implements PanacheRepository<UserEntity> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all users ordered by username.
|
||||||
|
*/
|
||||||
|
@Override
|
||||||
|
public List<UserEntity> listAll() {
|
||||||
|
return list("ORDER BY username");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a user by username.
|
||||||
|
*
|
||||||
|
* @param username the login name
|
||||||
|
* @return the entity or null if not found
|
||||||
|
*/
|
||||||
|
public UserEntity findByUsername(final String username) {
|
||||||
|
return find("username", username).firstResult();
|
||||||
|
}
|
||||||
|
}
|
||||||
73
src/main/java/de/neitzel/roleplay/fascade/AuthResource.java
Normal file
73
src/main/java/de/neitzel/roleplay/fascade/AuthResource.java
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import io.quarkus.security.identity.SecurityIdentity;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.ForbiddenException;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.NewCookie;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
import org.eclipse.microprofile.config.inject.ConfigProperty;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST resource for auth-related endpoints: current user info and logout.
|
||||||
|
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full paths are /api/v1/auth/me and /api/v1/auth/logout.
|
||||||
|
*/
|
||||||
|
@Path("auth")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
public class AuthResource {
|
||||||
|
|
||||||
|
private static final String DEFAULT_COOKIE_NAME = "quarkus-credential";
|
||||||
|
|
||||||
|
private final SecurityIdentity securityIdentity;
|
||||||
|
private final String sessionCookieName;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public AuthResource(
|
||||||
|
final SecurityIdentity securityIdentity,
|
||||||
|
@ConfigProperty(name = "quarkus.http.auth.form.cookie-name", defaultValue = DEFAULT_COOKIE_NAME)
|
||||||
|
final String sessionCookieName) {
|
||||||
|
this.securityIdentity = securityIdentity;
|
||||||
|
this.sessionCookieName = sessionCookieName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the current user's username and roles. Used by the frontend to check login state.
|
||||||
|
* Returns 401 when not authenticated (handled by Quarkus permission policy).
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
@Path("me")
|
||||||
|
public Map<String, Object> me() {
|
||||||
|
if (securityIdentity.isAnonymous()) {
|
||||||
|
throw new ForbiddenException("Not authenticated");
|
||||||
|
}
|
||||||
|
return Map.of(
|
||||||
|
"username", securityIdentity.getPrincipal().getName(),
|
||||||
|
"roles", securityIdentity.getRoles().stream().collect(Collectors.toList()));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logs out the current user by clearing the session cookie.
|
||||||
|
* Returns 204 No Content. Requires authenticated user.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
@Path("logout")
|
||||||
|
@Produces(MediaType.TEXT_PLAIN)
|
||||||
|
public Response logout() {
|
||||||
|
if (securityIdentity.isAnonymous()) {
|
||||||
|
throw new ForbiddenException("Not authenticated");
|
||||||
|
}
|
||||||
|
NewCookie clearCookie = new NewCookie.Builder(sessionCookieName)
|
||||||
|
.value("")
|
||||||
|
.path("/")
|
||||||
|
.maxAge(0)
|
||||||
|
.build();
|
||||||
|
return Response.noContent().cookie(clearCookie).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
58
src/main/java/de/neitzel/roleplay/fascade/UsersResource.java
Normal file
58
src/main/java/de/neitzel/roleplay/fascade/UsersResource.java
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.business.UserService;
|
||||||
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
|
||||||
|
import jakarta.annotation.security.RolesAllowed;
|
||||||
|
import jakarta.inject.Inject;
|
||||||
|
import jakarta.ws.rs.BadRequestException;
|
||||||
|
import jakarta.ws.rs.Consumes;
|
||||||
|
import jakarta.ws.rs.GET;
|
||||||
|
import jakarta.ws.rs.POST;
|
||||||
|
import jakarta.ws.rs.Path;
|
||||||
|
import jakarta.ws.rs.Produces;
|
||||||
|
import jakarta.ws.rs.core.MediaType;
|
||||||
|
import jakarta.ws.rs.core.Response;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST resource for admin-only user management: list users and create user.
|
||||||
|
* Path is relative to {@code quarkus.rest.path} (/api/v1), so full path is /api/v1/admin/users.
|
||||||
|
*/
|
||||||
|
@Path("admin/users")
|
||||||
|
@Produces(MediaType.APPLICATION_JSON)
|
||||||
|
@Consumes(MediaType.APPLICATION_JSON)
|
||||||
|
@RolesAllowed("admin")
|
||||||
|
public class UsersResource {
|
||||||
|
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
public UsersResource(final UserService userService) {
|
||||||
|
this.userService = userService;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all users (id, username, role). Admin only.
|
||||||
|
*/
|
||||||
|
@GET
|
||||||
|
public List<UserSummary> listUsers() {
|
||||||
|
return userService.listUsers();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new user with the given username, password, and role. Admin only.
|
||||||
|
* Returns 201 with the created user summary.
|
||||||
|
*/
|
||||||
|
@POST
|
||||||
|
public Response createUser(final CreateUserRequest request) {
|
||||||
|
try {
|
||||||
|
UserSummary created = userService.createUser(request);
|
||||||
|
return Response.status(Response.Status.CREATED).entity(created).build();
|
||||||
|
} catch (final IllegalArgumentException e) {
|
||||||
|
throw new BadRequestException(e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,6 +3,23 @@ quarkus:
|
|||||||
name: roleplay
|
name: roleplay
|
||||||
http:
|
http:
|
||||||
root-path: /
|
root-path: /
|
||||||
|
# Form-based auth: SPA mode (no redirects; 200/401). Session in encrypted cookie.
|
||||||
|
auth:
|
||||||
|
form:
|
||||||
|
enabled: true
|
||||||
|
login-page: ""
|
||||||
|
landing-page: ""
|
||||||
|
error-page: ""
|
||||||
|
http-only-cookie: false
|
||||||
|
session:
|
||||||
|
encryption-key: roleplay-session-secret-key-min-16chars
|
||||||
|
permission:
|
||||||
|
form-login:
|
||||||
|
paths: /j_security_check
|
||||||
|
policy: permit
|
||||||
|
api:
|
||||||
|
paths: /api/v1/*
|
||||||
|
policy: authenticated
|
||||||
datasource:
|
datasource:
|
||||||
db-kind: h2
|
db-kind: h2
|
||||||
jdbc:
|
jdbc:
|
||||||
@ -28,3 +45,10 @@ quarkus:
|
|||||||
level: DEBUG
|
level: DEBUG
|
||||||
"org.jboss.resteasy.reactive.client.logging":
|
"org.jboss.resteasy.reactive.client.logging":
|
||||||
level: DEBUG
|
level: DEBUG
|
||||||
|
# Login debugging: form auth and security identity
|
||||||
|
"io.quarkus.vertx.http.runtime.security":
|
||||||
|
level: DEBUG
|
||||||
|
"io.quarkus.security":
|
||||||
|
level: DEBUG
|
||||||
|
"de.neitzel.roleplay":
|
||||||
|
level: INFO
|
||||||
|
|||||||
@ -6,5 +6,6 @@
|
|||||||
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
||||||
|
|
||||||
<include file="db/migration/v001__scenarios_and_characters.xml"/>
|
<include file="db/migration/v001__scenarios_and_characters.xml"/>
|
||||||
|
<include file="db/migration/v002__users_and_roles.xml"/>
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|
||||||
|
|||||||
39
src/main/resources/db/migration/v002__users_and_roles.xml
Normal file
39
src/main/resources/db/migration/v002__users_and_roles.xml
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd">
|
||||||
|
|
||||||
|
<changeSet id="002-1-create-rp-user-table" author="roleplay">
|
||||||
|
<createTable tableName="rp_user">
|
||||||
|
<column name="id" type="uuid">
|
||||||
|
<constraints primaryKey="true" nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="username" type="varchar(255)">
|
||||||
|
<constraints nullable="false" unique="true"/>
|
||||||
|
</column>
|
||||||
|
<column name="password" type="varchar(255)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
<column name="role" type="varchar(50)">
|
||||||
|
<constraints nullable="false"/>
|
||||||
|
</column>
|
||||||
|
</createTable>
|
||||||
|
<comment>Stores application users for form-based auth. Default login: admin / changeme.</comment>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="002-2-seed-admin-user" author="roleplay">
|
||||||
|
<insert tableName="rp_user">
|
||||||
|
<column name="id" value="11111111-1111-1111-1111-111111111111"/>
|
||||||
|
<column name="username" value="admin"/>
|
||||||
|
<column name="password" value="$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO"/>
|
||||||
|
<column name="role" value="admin"/>
|
||||||
|
</insert>
|
||||||
|
<rollback>
|
||||||
|
<delete tableName="rp_user">
|
||||||
|
<where>id = '11111111-1111-1111-1111-111111111111'</where>
|
||||||
|
</delete>
|
||||||
|
</rollback>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
@ -23,6 +23,10 @@ tags:
|
|||||||
description: List and retrieve saved scenario templates
|
description: List and retrieve saved scenario templates
|
||||||
- name: characters
|
- name: characters
|
||||||
description: List and retrieve saved character templates
|
description: List and retrieve saved character templates
|
||||||
|
- name: auth
|
||||||
|
description: Authentication (current user, logout)
|
||||||
|
- name: users
|
||||||
|
description: User management (admin only)
|
||||||
|
|
||||||
paths:
|
paths:
|
||||||
|
|
||||||
@ -411,6 +415,84 @@ paths:
|
|||||||
schema:
|
schema:
|
||||||
$ref: '#/components/schemas/ErrorResponse'
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
|
||||||
|
/auth/me:
|
||||||
|
get:
|
||||||
|
operationId: getAuthMe
|
||||||
|
summary: Current user info
|
||||||
|
description: Returns the authenticated user's username and roles. Requires session cookie.
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: Current user.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/AuthMeResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
|
||||||
|
/auth/logout:
|
||||||
|
post:
|
||||||
|
operationId: logout
|
||||||
|
summary: Log out
|
||||||
|
description: Clears the session cookie. Requires authenticated user.
|
||||||
|
tags:
|
||||||
|
- auth
|
||||||
|
responses:
|
||||||
|
"204":
|
||||||
|
description: Logged out.
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
|
||||||
|
/admin/users:
|
||||||
|
get:
|
||||||
|
operationId: listUsers
|
||||||
|
summary: List users (admin only)
|
||||||
|
description: Returns all users (id, username, role). Admin role required.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: List of users.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserListResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
post:
|
||||||
|
operationId: createUser
|
||||||
|
summary: Create user (admin only)
|
||||||
|
description: Creates a new user with the given username, password, and role.
|
||||||
|
tags:
|
||||||
|
- users
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/CreateUserRequest'
|
||||||
|
responses:
|
||||||
|
"201":
|
||||||
|
description: User created.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/UserSummary'
|
||||||
|
"400":
|
||||||
|
description: Invalid request (e.g. username already exists).
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
$ref: '#/components/schemas/ErrorResponse'
|
||||||
|
"401":
|
||||||
|
description: Not authenticated.
|
||||||
|
"403":
|
||||||
|
description: Forbidden (admin only).
|
||||||
|
|
||||||
components:
|
components:
|
||||||
|
|
||||||
parameters:
|
parameters:
|
||||||
@ -1017,6 +1099,70 @@ components:
|
|||||||
- type
|
- type
|
||||||
- title
|
- title
|
||||||
|
|
||||||
|
# ─── Auth and users ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
AuthMeResponse:
|
||||||
|
type: object
|
||||||
|
description: Current authenticated user.
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
roles:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
type: string
|
||||||
|
description: Assigned roles (e.g. admin, user).
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- roles
|
||||||
|
|
||||||
|
UserSummary:
|
||||||
|
type: object
|
||||||
|
description: User without password (for list and create response).
|
||||||
|
properties:
|
||||||
|
id:
|
||||||
|
type: string
|
||||||
|
format: uuid
|
||||||
|
description: User UUID.
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
description: Role (admin or user).
|
||||||
|
required:
|
||||||
|
- id
|
||||||
|
- username
|
||||||
|
- role
|
||||||
|
|
||||||
|
UserListResponse:
|
||||||
|
type: array
|
||||||
|
items:
|
||||||
|
$ref: '#/components/schemas/UserSummary'
|
||||||
|
description: List of users (admin only).
|
||||||
|
|
||||||
|
CreateUserRequest:
|
||||||
|
type: object
|
||||||
|
description: Request to create a new user (admin only).
|
||||||
|
properties:
|
||||||
|
username:
|
||||||
|
type: string
|
||||||
|
description: Login name.
|
||||||
|
password:
|
||||||
|
type: string
|
||||||
|
description: Plain-text password (hashed on server).
|
||||||
|
role:
|
||||||
|
type: string
|
||||||
|
enum:
|
||||||
|
- admin
|
||||||
|
- user
|
||||||
|
description: Role to assign.
|
||||||
|
required:
|
||||||
|
- username
|
||||||
|
- password
|
||||||
|
- role
|
||||||
|
|
||||||
# ─── Error ────────────────────────────────────────────────────────────────
|
# ─── Error ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
ErrorResponse:
|
ErrorResponse:
|
||||||
|
|||||||
@ -1,25 +1,37 @@
|
|||||||
import {BrowserRouter, Route, Routes} from 'react-router-dom'
|
import { BrowserRouter, Route, Routes } from 'react-router-dom'
|
||||||
import AppLayout from './components/AppLayout'
|
import AppLayout from './components/AppLayout'
|
||||||
|
import ProtectedRoute from './components/ProtectedRoute'
|
||||||
|
import { AuthProvider } from './contexts/AuthContext'
|
||||||
import StartPage from './pages/StartPage'
|
import StartPage from './pages/StartPage'
|
||||||
import SessionPage from './pages/SessionPage'
|
import SessionPage from './pages/SessionPage'
|
||||||
import ScenariosPage from './pages/ScenariosPage'
|
import ScenariosPage from './pages/ScenariosPage'
|
||||||
import CharactersPage from './pages/CharactersPage'
|
import CharactersPage from './pages/CharactersPage'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Root application component. Sets up client-side routing with app shell:
|
* Root application component. Sets up client-side routing with app shell:
|
||||||
* Home (start flow), Scenarios, Characters, and Session.
|
* Login (public), then Home, Scenarios, Characters, Session (protected).
|
||||||
*/
|
*/
|
||||||
export default function App() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<Routes>
|
<AuthProvider>
|
||||||
<Route element={<AppLayout/>}>
|
<Routes>
|
||||||
<Route path="/" element={<StartPage/>}/>
|
<Route path="/login" element={<LoginPage />} />
|
||||||
<Route path="/scenarios" element={<ScenariosPage/>}/>
|
<Route
|
||||||
<Route path="/characters" element={<CharactersPage/>}/>
|
element={
|
||||||
<Route path="/session/:sessionId" element={<SessionPage/>}/>
|
<ProtectedRoute>
|
||||||
</Route>
|
<AppLayout />
|
||||||
</Routes>
|
</ProtectedRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route path="/" element={<StartPage />} />
|
||||||
|
<Route path="/scenarios" element={<ScenariosPage />} />
|
||||||
|
<Route path="/characters" element={<CharactersPage />} />
|
||||||
|
<Route path="/session/:sessionId" element={<SessionPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,6 +7,13 @@
|
|||||||
* the project in an IDE to make these imports resolve.
|
* the project in an IDE to make these imports resolve.
|
||||||
*/
|
*/
|
||||||
export * from './generated/index'
|
export * from './generated/index'
|
||||||
|
import { Configuration } from './generated/runtime'
|
||||||
|
|
||||||
/** Base URL used by the generated fetch client. Always points to /api/v1. */
|
/** Base URL used by the generated fetch client. Always points to /api/v1. */
|
||||||
export const API_BASE = '/api/v1'
|
export const API_BASE = '/api/v1'
|
||||||
|
|
||||||
|
/** Default configuration for API clients; sends session cookie (credentials: 'include'). */
|
||||||
|
export const apiConfiguration = new Configuration({
|
||||||
|
basePath: API_BASE,
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
|||||||
@ -1,12 +1,15 @@
|
|||||||
import {AppBar, Box, Toolbar, Typography} from '@mui/material'
|
import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material'
|
||||||
import {Link as RouterLink, Outlet, useLocation} from 'react-router-dom'
|
import { Link as RouterLink, Outlet, useLocation } from 'react-router-dom'
|
||||||
import {Link} from '@mui/material'
|
import { Link } from '@mui/material'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
|
* Persistent app shell with top navigation. Renders the current route's page via Outlet.
|
||||||
|
* Shows current username and Logout when authenticated.
|
||||||
*/
|
*/
|
||||||
export default function AppLayout() {
|
export default function AppLayout() {
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
const { user, logout } = useAuth()
|
||||||
const navItems = [
|
const navItems = [
|
||||||
{ to: '/', label: 'Home' },
|
{ to: '/', label: 'Home' },
|
||||||
{ to: '/scenarios', label: 'Scenarios' },
|
{ to: '/scenarios', label: 'Scenarios' },
|
||||||
@ -30,7 +33,7 @@ export default function AppLayout() {
|
|||||||
>
|
>
|
||||||
RolePlay
|
RolePlay
|
||||||
</Typography>
|
</Typography>
|
||||||
<Box sx={{ display: 'flex', gap: 1 }}>
|
<Box sx={{ display: 'flex', gap: 1, alignItems: 'center' }}>
|
||||||
{navItems.map(({ to, label }) => (
|
{navItems.map(({ to, label }) => (
|
||||||
<Link
|
<Link
|
||||||
key={to}
|
key={to}
|
||||||
@ -43,6 +46,16 @@ export default function AppLayout() {
|
|||||||
{label}
|
{label}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
|
{user && (
|
||||||
|
<>
|
||||||
|
<Typography variant="body2" sx={{ px: 1 }}>
|
||||||
|
{user.username}
|
||||||
|
</Typography>
|
||||||
|
<Button color="inherit" size="small" onClick={() => logout()}>
|
||||||
|
Logout
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</Box>
|
</Box>
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
</AppBar>
|
</AppBar>
|
||||||
|
|||||||
41
src/main/web/src/components/ProtectedRoute.tsx
Normal file
41
src/main/web/src/components/ProtectedRoute.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
import { useEffect, type ReactNode } from 'react'
|
||||||
|
import { Navigate, useLocation } from 'react-router-dom'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
import { Box, CircularProgress, Typography } from '@mui/material'
|
||||||
|
|
||||||
|
interface ProtectedRouteProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wraps content that requires authentication. If not logged in, redirects to /login.
|
||||||
|
* Shows a loading spinner while auth state is being determined.
|
||||||
|
*/
|
||||||
|
export default function ProtectedRoute({ children }: ProtectedRouteProps) {
|
||||||
|
const { user, loading } = useAuth()
|
||||||
|
const location = useLocation()
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Box
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
flexDirection: 'column',
|
||||||
|
alignItems: 'center',
|
||||||
|
justifyContent: 'center',
|
||||||
|
minHeight: '40vh',
|
||||||
|
gap: 2,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CircularProgress />
|
||||||
|
<Typography color="text.secondary">Checking authentication…</Typography>
|
||||||
|
</Box>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return <Navigate to="/login" state={{ from: location }} replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
106
src/main/web/src/contexts/AuthContext.tsx
Normal file
106
src/main/web/src/contexts/AuthContext.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import {
|
||||||
|
createContext,
|
||||||
|
useCallback,
|
||||||
|
useContext,
|
||||||
|
useEffect,
|
||||||
|
useState,
|
||||||
|
type ReactNode,
|
||||||
|
} from 'react'
|
||||||
|
import { useNavigate } from 'react-router-dom'
|
||||||
|
|
||||||
|
const API_BASE = '/api/v1'
|
||||||
|
|
||||||
|
export interface AuthUser {
|
||||||
|
username: string
|
||||||
|
roles: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
user: AuthUser | null
|
||||||
|
loading: boolean
|
||||||
|
error: boolean
|
||||||
|
logout: () => Promise<void>
|
||||||
|
refresh: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
|
|
||||||
|
export function useAuth(): AuthContextValue {
|
||||||
|
const value = useContext(AuthContext)
|
||||||
|
if (!value) {
|
||||||
|
throw new Error('useAuth must be used within AuthProvider')
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AuthProviderProps {
|
||||||
|
children: ReactNode
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Provides auth state (current user, loading, error) and logout.
|
||||||
|
* Fetches GET /api/v1/auth/me with credentials so the session cookie is sent.
|
||||||
|
*/
|
||||||
|
export function AuthProvider({ children }: AuthProviderProps) {
|
||||||
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [error, setError] = useState(false)
|
||||||
|
const navigate = useNavigate()
|
||||||
|
|
||||||
|
const fetchMe = useCallback(async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(false)
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE}/auth/me`, { credentials: 'include' })
|
||||||
|
if (res.ok) {
|
||||||
|
const data = await res.json()
|
||||||
|
setUser({
|
||||||
|
username: data.username ?? '',
|
||||||
|
roles: Array.isArray(data.roles) ? data.roles : [],
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
setUser(null)
|
||||||
|
if (res.status === 401) {
|
||||||
|
setError(false)
|
||||||
|
} else {
|
||||||
|
setError(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setUser(null)
|
||||||
|
setError(true)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchMe()
|
||||||
|
}, [fetchMe])
|
||||||
|
|
||||||
|
const logout = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await fetch(`${API_BASE}/auth/logout`, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setUser(null)
|
||||||
|
navigate('/login', { replace: true })
|
||||||
|
}
|
||||||
|
}, [navigate])
|
||||||
|
|
||||||
|
const value: AuthContextValue = {
|
||||||
|
user,
|
||||||
|
loading,
|
||||||
|
error,
|
||||||
|
logout,
|
||||||
|
refresh: fetchMe,
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider value={value}>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -19,10 +19,10 @@ import AddIcon from '@mui/icons-material/Add'
|
|||||||
import DeleteIcon from '@mui/icons-material/Delete'
|
import DeleteIcon from '@mui/icons-material/Delete'
|
||||||
import EditIcon from '@mui/icons-material/Edit'
|
import EditIcon from '@mui/icons-material/Edit'
|
||||||
import type {CharacterDefinition, CreateCharacterRequest} from '../api/generated/index'
|
import type {CharacterDefinition, CreateCharacterRequest} from '../api/generated/index'
|
||||||
import {CharactersApi, Configuration} from '../api/generated/index'
|
import { CharactersApi } from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
|
||||||
const API_BASE = '/api/v1'
|
const charactersApi = new CharactersApi(apiConfiguration)
|
||||||
const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE}))
|
|
||||||
|
|
||||||
/** Parse comma- or newline-separated string into trimmed non-empty strings. */
|
/** Parse comma- or newline-separated string into trimmed non-empty strings. */
|
||||||
function parseList(value: string | undefined): string[] {
|
function parseList(value: string | undefined): string[] {
|
||||||
|
|||||||
106
src/main/web/src/pages/LoginPage.tsx
Normal file
106
src/main/web/src/pages/LoginPage.tsx
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, useLocation } from 'react-router-dom'
|
||||||
|
import {
|
||||||
|
Alert,
|
||||||
|
Box,
|
||||||
|
Button,
|
||||||
|
Container,
|
||||||
|
Paper,
|
||||||
|
TextField,
|
||||||
|
Typography,
|
||||||
|
} from '@mui/material'
|
||||||
|
import { useAuth } from '../contexts/AuthContext'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Login page. POSTs credentials to /j_security_check (form-based auth).
|
||||||
|
* On success refreshes auth state and redirects to home or the previously attempted URL.
|
||||||
|
*/
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const location = useLocation()
|
||||||
|
const { refresh } = useAuth()
|
||||||
|
const [username, setUsername] = useState('')
|
||||||
|
const [password, setPassword] = useState('')
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const from = (location.state as { from?: { pathname: string } })?.from?.pathname ?? '/'
|
||||||
|
|
||||||
|
async function handleSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const formData = new URLSearchParams()
|
||||||
|
formData.append('j_username', username)
|
||||||
|
formData.append('j_password', password)
|
||||||
|
const res = await fetch('/j_security_check', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: formData.toString(),
|
||||||
|
credentials: 'include',
|
||||||
|
})
|
||||||
|
if (res.ok) {
|
||||||
|
await refresh()
|
||||||
|
navigate(from, { replace: true })
|
||||||
|
} else {
|
||||||
|
setError('Invalid credentials')
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
setError('Login failed')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Container maxWidth="sm">
|
||||||
|
<Box sx={{ pt: 4 }}>
|
||||||
|
<Paper elevation={1} sx={{ p: 3 }}>
|
||||||
|
<Typography variant="h5" component="h1" gutterBottom>
|
||||||
|
Sign in
|
||||||
|
</Typography>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||||
|
Default: admin / changeme
|
||||||
|
</Typography>
|
||||||
|
{error && (
|
||||||
|
<Alert severity="error" sx={{ mb: 2 }}>
|
||||||
|
{error}
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
<form onSubmit={handleSubmit}>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Username"
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoComplete="username"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
label="Password"
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
margin="normal"
|
||||||
|
required
|
||||||
|
autoComplete="current-password"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
fullWidth
|
||||||
|
variant="contained"
|
||||||
|
disabled={submitting}
|
||||||
|
sx={{ mt: 3, mb: 2 }}
|
||||||
|
>
|
||||||
|
{submitting ? 'Signing in…' : 'Sign in'}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
</Paper>
|
||||||
|
</Box>
|
||||||
|
</Container>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -30,11 +30,11 @@ import type {
|
|||||||
ScenarioSetup,
|
ScenarioSetup,
|
||||||
ScenarioSummary,
|
ScenarioSummary,
|
||||||
} from '../api/generated/index'
|
} from '../api/generated/index'
|
||||||
import {CharactersApi, Configuration, ScenariosApi} from '../api/generated/index'
|
import { CharactersApi, ScenariosApi } from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
|
||||||
const API_BASE = '/api/v1'
|
const scenariosApi = new ScenariosApi(apiConfiguration)
|
||||||
const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
|
const charactersApi = new CharactersApi(apiConfiguration)
|
||||||
const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE}))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Scenarios management page. List cards with New/Edit/Delete/Start; form with character assignment.
|
* Scenarios management page. List cards with New/Edit/Delete/Start; form with character assignment.
|
||||||
|
|||||||
@ -38,14 +38,14 @@ import type {
|
|||||||
TurnRequest,
|
TurnRequest,
|
||||||
UpdateSessionRequest,
|
UpdateSessionRequest,
|
||||||
} from '../api/generated/index'
|
} from '../api/generated/index'
|
||||||
import {Configuration, SessionsApi, TurnsApi, UserActionRequestTypeEnum,} from '../api/generated/index'
|
import { SessionsApi, TurnsApi, UserActionRequestTypeEnum } from '../api/generated/index'
|
||||||
import NarrativeView from '../components/NarrativeView'
|
import NarrativeView from '../components/NarrativeView'
|
||||||
import SuggestionList from '../components/SuggestionList'
|
import SuggestionList from '../components/SuggestionList'
|
||||||
import ActionInput from '../components/ActionInput'
|
import ActionInput from '../components/ActionInput'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
|
||||||
const API_BASE = '/api/v1'
|
const sessionsApi = new SessionsApi(apiConfiguration)
|
||||||
const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE}))
|
const turnsApi = new TurnsApi(apiConfiguration)
|
||||||
const turnsApi = new TurnsApi(new Configuration({basePath: API_BASE}))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Active role-play session page. Loads the session state, displays the
|
* Active role-play session page. Loads the session state, displays the
|
||||||
|
|||||||
@ -39,19 +39,16 @@ import type {
|
|||||||
ScenarioSummary,
|
ScenarioSummary,
|
||||||
} from '../api/generated/index'
|
} from '../api/generated/index'
|
||||||
import {
|
import {
|
||||||
Configuration,
|
|
||||||
CreateSessionRequestSafetyLevelEnum,
|
CreateSessionRequestSafetyLevelEnum,
|
||||||
ModelsApi,
|
ModelsApi,
|
||||||
ScenariosApi,
|
ScenariosApi,
|
||||||
SessionsApi,
|
SessionsApi,
|
||||||
} from '../api/generated/index'
|
} from '../api/generated/index'
|
||||||
|
import { apiConfiguration } from '../api'
|
||||||
|
|
||||||
/***** API base path – must match quarkus.rest.path in application.yml */
|
const modelsApi = new ModelsApi(apiConfiguration)
|
||||||
const API_BASE = '/api/v1'
|
const sessionsApi = new SessionsApi(apiConfiguration)
|
||||||
|
const scenariosApi = new ScenariosApi(apiConfiguration)
|
||||||
const modelsApi = new ModelsApi(new Configuration({basePath: API_BASE}))
|
|
||||||
const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE}))
|
|
||||||
const scenariosApi = new ScenariosApi(new Configuration({basePath: API_BASE}))
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Landing page where the user selects an Ollama model and optional settings
|
* Landing page where the user selects an Ollama model and optional settings
|
||||||
|
|||||||
@ -14,6 +14,11 @@ export default defineConfig({
|
|||||||
target: 'http://localhost:8080',
|
target: 'http://localhost:8080',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
// Form-based login; must reach Quarkus in dev
|
||||||
|
'/j_security_check': {
|
||||||
|
target: 'http://localhost:8080',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,36 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import io.quarkus.elytron.security.common.BcryptUtil;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates BCrypt hash for the default admin password for use in Liquibase seed.
|
||||||
|
* Run: mvn test -Dtest=BcryptHashGenerator#generateChangemeHash
|
||||||
|
* Then paste the printed hash into v002__users_and_roles.xml seed changeset.
|
||||||
|
*/
|
||||||
|
final class BcryptHashGenerator {
|
||||||
|
|
||||||
|
/** BCrypt hash stored in v002__users_and_roles.xml for admin user (password: changeme). */
|
||||||
|
private static final String SEEDED_ADMIN_PASSWORD_HASH =
|
||||||
|
"$2a$10$dFA336yJOw3.pogwU.vXVu3BRRfBr1yjhGC6O6nfC2qGFA5e29.cO";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void generateChangemeHash() {
|
||||||
|
String hash = BcryptUtil.bcryptHash("changeme");
|
||||||
|
System.out.println("BCrypt hash for 'changeme': " + hash);
|
||||||
|
assert hash != null && hash.startsWith("$2");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verifies the seeded admin password hash in the migration matches "changeme".
|
||||||
|
* If this fails, run generateChangemeHash() and update v002__users_and_roles.xml with the new hash.
|
||||||
|
*/
|
||||||
|
@Test
|
||||||
|
void seededAdminHashMatchesChangeme() {
|
||||||
|
boolean matches = BcryptUtil.matches("changeme", SEEDED_ADMIN_PASSWORD_HASH);
|
||||||
|
assertTrue(matches, "Seeded admin password hash in v002__users_and_roles.xml must match 'changeme'. "
|
||||||
|
+ "Run generateChangemeHash() and update the migration with the printed hash.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package de.neitzel.roleplay.business;
|
||||||
|
|
||||||
|
import de.neitzel.roleplay.common.CreateUserRequest;
|
||||||
|
import de.neitzel.roleplay.common.UserSummary;
|
||||||
|
import de.neitzel.roleplay.data.UserEntity;
|
||||||
|
import de.neitzel.roleplay.data.UserRepository;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unit tests for {@link UserService}.
|
||||||
|
*/
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
userService = new UserService(userRepository);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void listUsersReturnsMappedSummaries() {
|
||||||
|
UserEntity entity = new UserEntity();
|
||||||
|
entity.setId(UUID.fromString("11111111-1111-1111-1111-111111111111"));
|
||||||
|
entity.setUsername("alice");
|
||||||
|
entity.setRole("user");
|
||||||
|
when(userRepository.listAll()).thenReturn(List.of(entity));
|
||||||
|
|
||||||
|
List<UserSummary> result = userService.listUsers();
|
||||||
|
|
||||||
|
assertEquals(1, result.size());
|
||||||
|
UserSummary summary = result.get(0);
|
||||||
|
assertEquals(UUID.fromString("11111111-1111-1111-1111-111111111111"), summary.getId());
|
||||||
|
assertEquals("alice", summary.getUsername());
|
||||||
|
assertEquals("user", summary.getRole());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsBlankUsername() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("", "secret", "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Username is required", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsBlankPassword() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("alice", "", "user");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Password is required", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsInvalidRole() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("alice", "secret", "superuser");
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Role must be 'admin' or 'user'", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void createUserRejectsDuplicateUsername() {
|
||||||
|
CreateUserRequest request = new CreateUserRequest("alice", "secret", "user");
|
||||||
|
when(userRepository.findByUsername("alice")).thenReturn(new UserEntity());
|
||||||
|
|
||||||
|
IllegalArgumentException e = assertThrows(IllegalArgumentException.class, () -> userService.createUser(request));
|
||||||
|
assertEquals("Username already exists: alice", e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
package de.neitzel.roleplay.fascade;
|
||||||
|
|
||||||
|
import io.quarkus.test.junit.QuarkusTest;
|
||||||
|
import io.restassured.http.Cookies;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static io.restassured.RestAssured.given;
|
||||||
|
import static org.hamcrest.CoreMatchers.is;
|
||||||
|
import static org.hamcrest.Matchers.hasItem;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Integration tests for form-based auth: login, auth/me, and protected API access.
|
||||||
|
*/
|
||||||
|
@QuarkusTest
|
||||||
|
class AuthIntegrationTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedRequestToApiReturns401() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/scenarios")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void unauthenticatedRequestToAuthMeReturns401() {
|
||||||
|
given()
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/auth/me")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void loginThenAuthMeAndScenariosSucceed() {
|
||||||
|
Cookies cookies = given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "changeme")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.extract()
|
||||||
|
.detailedCookies();
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/auth/me")
|
||||||
|
.then()
|
||||||
|
.statusCode(200)
|
||||||
|
.body("username", is("admin"))
|
||||||
|
.body("roles", hasItem("admin"));
|
||||||
|
|
||||||
|
given()
|
||||||
|
.cookies(cookies)
|
||||||
|
.when()
|
||||||
|
.get("/api/v1/scenarios")
|
||||||
|
.then()
|
||||||
|
.statusCode(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void invalidLoginReturns401() {
|
||||||
|
given()
|
||||||
|
.contentType("application/x-www-form-urlencoded")
|
||||||
|
.formParam("j_username", "admin")
|
||||||
|
.formParam("j_password", "wrong")
|
||||||
|
.when()
|
||||||
|
.post("/j_security_check")
|
||||||
|
.then()
|
||||||
|
.statusCode(401);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user