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:
Konrad Neitzel 2026-02-22 11:28:04 +01:00
parent 4c1584ec27
commit 2c61ab5fc9
28 changed files with 1252 additions and 32 deletions

View File

@ -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
View File

@ -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>

View 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.");
}
}
}

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

View File

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

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

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

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

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

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

View File

@ -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

View File

@ -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>

View 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>

View File

@ -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:

View File

@ -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>
<AuthProvider>
<Routes> <Routes>
<Route element={<AppLayout/>}> <Route path="/login" element={<LoginPage />} />
<Route
element={
<ProtectedRoute>
<AppLayout />
</ProtectedRoute>
}
>
<Route path="/" element={<StartPage />} /> <Route path="/" element={<StartPage />} />
<Route path="/scenarios" element={<ScenariosPage />} /> <Route path="/scenarios" element={<ScenariosPage />} />
<Route path="/characters" element={<CharactersPage />} /> <Route path="/characters" element={<CharactersPage />} />
<Route path="/session/:sessionId" element={<SessionPage />} /> <Route path="/session/:sessionId" element={<SessionPage />} />
</Route> </Route>
</Routes> </Routes>
</AuthProvider>
</BrowserRouter> </BrowserRouter>
) )
} }

View File

@ -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',
})

View File

@ -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>

View 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}</>
}

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

View File

@ -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[] {

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

View File

@ -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.

View File

@ -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

View File

@ -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

View File

@ -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,
},
}, },
}, },
}) })

View File

@ -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.");
}
}

View File

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

View File

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