diff --git a/README.md b/README.md index 28b46b6..01dc0c7 100644 --- a/README.md +++ b/README.md @@ -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/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 ```zsh diff --git a/pom.xml b/pom.xml index 404916d..03316eb 100644 --- a/pom.xml +++ b/pom.xml @@ -74,6 +74,10 @@ io.quarkus quarkus-hibernate-orm-panache + + io.quarkus + quarkus-security-jpa + io.quarkus quarkus-rest-client-config @@ -101,6 +105,16 @@ ${mockito.version} test + + io.quarkus + quarkus-junit5 + test + + + io.rest-assured + rest-assured + test + diff --git a/src/main/java/de/neitzel/roleplay/LoginDebugStartup.java b/src/main/java/de/neitzel/roleplay/LoginDebugStartup.java new file mode 100644 index 0000000..db24aea --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/LoginDebugStartup.java @@ -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."); + } + } +} diff --git a/src/main/java/de/neitzel/roleplay/business/UserService.java b/src/main/java/de/neitzel/roleplay/business/UserService.java new file mode 100644 index 0000000..8c21778 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/business/UserService.java @@ -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 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()); + } +} diff --git a/src/main/java/de/neitzel/roleplay/common/CreateUserRequest.java b/src/main/java/de/neitzel/roleplay/common/CreateUserRequest.java new file mode 100644 index 0000000..36468e4 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/CreateUserRequest.java @@ -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; + } +} diff --git a/src/main/java/de/neitzel/roleplay/common/UserSummary.java b/src/main/java/de/neitzel/roleplay/common/UserSummary.java new file mode 100644 index 0000000..0f1edee --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/common/UserSummary.java @@ -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; + } +} diff --git a/src/main/java/de/neitzel/roleplay/data/UserEntity.java b/src/main/java/de/neitzel/roleplay/data/UserEntity.java new file mode 100644 index 0000000..4dc4e8d --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/data/UserEntity.java @@ -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; + } +} diff --git a/src/main/java/de/neitzel/roleplay/data/UserRepository.java b/src/main/java/de/neitzel/roleplay/data/UserRepository.java new file mode 100644 index 0000000..9230ff2 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/data/UserRepository.java @@ -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 { + + /** + * Returns all users ordered by username. + */ + @Override + public List 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(); + } +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java b/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java new file mode 100644 index 0000000..08a6292 --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/AuthResource.java @@ -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 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(); + } +} diff --git a/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java b/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java new file mode 100644 index 0000000..e79656d --- /dev/null +++ b/src/main/java/de/neitzel/roleplay/fascade/UsersResource.java @@ -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 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()); + } + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 231334e..685f6b1 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,6 +3,23 @@ quarkus: name: roleplay http: 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: db-kind: h2 jdbc: @@ -28,3 +45,10 @@ quarkus: level: DEBUG "org.jboss.resteasy.reactive.client.logging": 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 diff --git a/src/main/resources/db/migration/changelog.xml b/src/main/resources/db/migration/changelog.xml index b775c7e..e776ff9 100644 --- a/src/main/resources/db/migration/changelog.xml +++ b/src/main/resources/db/migration/changelog.xml @@ -6,5 +6,6 @@ http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.24.xsd"> + diff --git a/src/main/resources/db/migration/v002__users_and_roles.xml b/src/main/resources/db/migration/v002__users_and_roles.xml new file mode 100644 index 0000000..4804bc5 --- /dev/null +++ b/src/main/resources/db/migration/v002__users_and_roles.xml @@ -0,0 +1,39 @@ + + + + + + + + + + + + + + + + + + + Stores application users for form-based auth. Default login: admin / changeme. + + + + + + + + + + + + id = '11111111-1111-1111-1111-111111111111' + + + + diff --git a/src/main/resources/openapi-roleplay-public-v1.yml b/src/main/resources/openapi-roleplay-public-v1.yml index e6b639b..1cf8b35 100644 --- a/src/main/resources/openapi-roleplay-public-v1.yml +++ b/src/main/resources/openapi-roleplay-public-v1.yml @@ -23,6 +23,10 @@ tags: description: List and retrieve saved scenario templates - name: characters description: List and retrieve saved character templates + - name: auth + description: Authentication (current user, logout) + - name: users + description: User management (admin only) paths: @@ -411,6 +415,84 @@ paths: schema: $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: parameters: @@ -1017,6 +1099,70 @@ components: - type - 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 ──────────────────────────────────────────────────────────────── ErrorResponse: diff --git a/src/main/web/src/App.tsx b/src/main/web/src/App.tsx index 4e1b2ef..4324924 100644 --- a/src/main/web/src/App.tsx +++ b/src/main/web/src/App.tsx @@ -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 ProtectedRoute from './components/ProtectedRoute' +import { AuthProvider } from './contexts/AuthContext' import StartPage from './pages/StartPage' import SessionPage from './pages/SessionPage' import ScenariosPage from './pages/ScenariosPage' import CharactersPage from './pages/CharactersPage' +import LoginPage from './pages/LoginPage' /** * 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() { return ( - - }> - }/> - }/> - }/> - }/> - - + + + } /> + + + + } + > + } /> + } /> + } /> + } /> + + + ) } diff --git a/src/main/web/src/api/index.ts b/src/main/web/src/api/index.ts index 4b7ccf4..cfc5730 100644 --- a/src/main/web/src/api/index.ts +++ b/src/main/web/src/api/index.ts @@ -7,6 +7,13 @@ * the project in an IDE to make these imports resolve. */ export * from './generated/index' +import { Configuration } from './generated/runtime' /** Base URL used by the generated fetch client. Always points to /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', +}) diff --git a/src/main/web/src/components/AppLayout.tsx b/src/main/web/src/components/AppLayout.tsx index 5399bd3..91bcfcd 100644 --- a/src/main/web/src/components/AppLayout.tsx +++ b/src/main/web/src/components/AppLayout.tsx @@ -1,12 +1,15 @@ -import {AppBar, Box, Toolbar, Typography} from '@mui/material' -import {Link as RouterLink, Outlet, useLocation} from 'react-router-dom' -import {Link} from '@mui/material' +import { AppBar, Box, Button, Toolbar, Typography } from '@mui/material' +import { Link as RouterLink, Outlet, useLocation } from 'react-router-dom' +import { Link } from '@mui/material' +import { useAuth } from '../contexts/AuthContext' /** * 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() { const location = useLocation() + const { user, logout } = useAuth() const navItems = [ { to: '/', label: 'Home' }, { to: '/scenarios', label: 'Scenarios' }, @@ -30,7 +33,7 @@ export default function AppLayout() { > RolePlay - + {navItems.map(({ to, label }) => ( ))} + {user && ( + <> + + {user.username} + + + + )} diff --git a/src/main/web/src/components/ProtectedRoute.tsx b/src/main/web/src/components/ProtectedRoute.tsx new file mode 100644 index 0000000..38425a9 --- /dev/null +++ b/src/main/web/src/components/ProtectedRoute.tsx @@ -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 ( + + + Checking authentication… + + ) + } + + if (!user) { + return + } + + return <>{children} +} diff --git a/src/main/web/src/contexts/AuthContext.tsx b/src/main/web/src/contexts/AuthContext.tsx new file mode 100644 index 0000000..be8c9cc --- /dev/null +++ b/src/main/web/src/contexts/AuthContext.tsx @@ -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 + refresh: () => Promise +} + +const AuthContext = createContext(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(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 ( + + {children} + + ) +} diff --git a/src/main/web/src/pages/CharactersPage.tsx b/src/main/web/src/pages/CharactersPage.tsx index 0268c95..82c5dcc 100644 --- a/src/main/web/src/pages/CharactersPage.tsx +++ b/src/main/web/src/pages/CharactersPage.tsx @@ -19,10 +19,10 @@ import AddIcon from '@mui/icons-material/Add' import DeleteIcon from '@mui/icons-material/Delete' import EditIcon from '@mui/icons-material/Edit' 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(new Configuration({basePath: API_BASE})) +const charactersApi = new CharactersApi(apiConfiguration) /** Parse comma- or newline-separated string into trimmed non-empty strings. */ function parseList(value: string | undefined): string[] { diff --git a/src/main/web/src/pages/LoginPage.tsx b/src/main/web/src/pages/LoginPage.tsx new file mode 100644 index 0000000..812100d --- /dev/null +++ b/src/main/web/src/pages/LoginPage.tsx @@ -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(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 ( + + + + + Sign in + + + Default: admin / changeme + + {error && ( + + {error} + + )} +
+ setUsername(e.target.value)} + margin="normal" + required + autoComplete="username" + autoFocus + /> + setPassword(e.target.value)} + margin="normal" + required + autoComplete="current-password" + /> + + +
+
+
+ ) +} diff --git a/src/main/web/src/pages/ScenariosPage.tsx b/src/main/web/src/pages/ScenariosPage.tsx index 681fad6..559aecd 100644 --- a/src/main/web/src/pages/ScenariosPage.tsx +++ b/src/main/web/src/pages/ScenariosPage.tsx @@ -30,11 +30,11 @@ import type { ScenarioSetup, ScenarioSummary, } 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(new Configuration({basePath: API_BASE})) -const charactersApi = new CharactersApi(new Configuration({basePath: API_BASE})) +const scenariosApi = new ScenariosApi(apiConfiguration) +const charactersApi = new CharactersApi(apiConfiguration) /** * Scenarios management page. List cards with New/Edit/Delete/Start; form with character assignment. diff --git a/src/main/web/src/pages/SessionPage.tsx b/src/main/web/src/pages/SessionPage.tsx index cfb311f..777cd7e 100644 --- a/src/main/web/src/pages/SessionPage.tsx +++ b/src/main/web/src/pages/SessionPage.tsx @@ -38,14 +38,14 @@ import type { TurnRequest, UpdateSessionRequest, } 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 SuggestionList from '../components/SuggestionList' import ActionInput from '../components/ActionInput' +import { apiConfiguration } from '../api' -const API_BASE = '/api/v1' -const sessionsApi = new SessionsApi(new Configuration({basePath: API_BASE})) -const turnsApi = new TurnsApi(new Configuration({basePath: API_BASE})) +const sessionsApi = new SessionsApi(apiConfiguration) +const turnsApi = new TurnsApi(apiConfiguration) /** * Active role-play session page. Loads the session state, displays the diff --git a/src/main/web/src/pages/StartPage.tsx b/src/main/web/src/pages/StartPage.tsx index 239d8ab..00ffd10 100644 --- a/src/main/web/src/pages/StartPage.tsx +++ b/src/main/web/src/pages/StartPage.tsx @@ -39,19 +39,16 @@ import type { ScenarioSummary, } from '../api/generated/index' import { - Configuration, CreateSessionRequestSafetyLevelEnum, ModelsApi, ScenariosApi, SessionsApi, } from '../api/generated/index' +import { apiConfiguration } from '../api' -/***** API base path – must match quarkus.rest.path in application.yml */ -const API_BASE = '/api/v1' - -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})) +const modelsApi = new ModelsApi(apiConfiguration) +const sessionsApi = new SessionsApi(apiConfiguration) +const scenariosApi = new ScenariosApi(apiConfiguration) /** * Landing page where the user selects an Ollama model and optional settings diff --git a/src/main/web/vite.config.ts b/src/main/web/vite.config.ts index 76949db..4cebb3a 100644 --- a/src/main/web/vite.config.ts +++ b/src/main/web/vite.config.ts @@ -14,6 +14,11 @@ export default defineConfig({ target: 'http://localhost:8080', changeOrigin: true, }, + // Form-based login; must reach Quarkus in dev + '/j_security_check': { + target: 'http://localhost:8080', + changeOrigin: true, + }, }, }, }) diff --git a/src/test/java/de/neitzel/roleplay/business/BcryptHashGenerator.java b/src/test/java/de/neitzel/roleplay/business/BcryptHashGenerator.java new file mode 100644 index 0000000..f02cad4 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/business/BcryptHashGenerator.java @@ -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."); + } +} diff --git a/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java b/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java new file mode 100644 index 0000000..cd8416a --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/business/UserServiceTest.java @@ -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 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()); + } + +} diff --git a/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java b/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java new file mode 100644 index 0000000..8347549 --- /dev/null +++ b/src/test/java/de/neitzel/roleplay/fascade/AuthIntegrationTest.java @@ -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); + } +}