Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 30 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,33 @@ Invoke-RestMethod -Uri http://localhost:8080/api/web/login -Method Post -WebSess
-Body (@{ username = "findmypacksadmin"; password = "findmypacksadmin" } | ConvertTo-Json)
```

## Command-line interface (Issue #7)

The CLI uses the REST API and never connects directly to PostgreSQL. Keep the application running and execute every CLI command through Docker Compose:

```sh
docker compose up --build -d --wait
docker compose --profile cli run --rm cli help
docker compose --profile cli run --rm cli health
docker compose --profile cli run --rm cli token findmypacksadmin findmypacksadmin
```

The `token` command prints a Bearer token. Put it in `FINDMYPACKS_TOKEN` before using the protected status command:

```sh
export FINDMYPACKS_TOKEN="token-returned-by-the-command"
docker compose --profile cli run --rm cli status
```

PowerShell:

```powershell
$env:FINDMYPACKS_TOKEN = "token-returned-by-the-command"
docker compose --profile cli run --rm cli status
```

`GET /api/cli/health` is public. `POST /api/cli/token` validates the initial username and password. Protected CLI endpoints require `Authorization: Bearer <token>` and return HTTP 401 for a missing or invalid token. Change the development credentials through `APP_ADMIN_USERNAME` and `APP_ADMIN_PASSWORD`; do not commit real credentials or tokens.

## Logs and request correlation

Each Web and CLI operation writes a single-line JSON event to stdout with an ISO 8601 UTC timestamp, level, layer, client, operation, status, request ID, and a short message. Database reads made by an operation produce a separate `db` event with the same request ID and client. Successful and failed login attempts are logged without passwords or request payloads. SQL statements and bind values are not logged.
Expand All @@ -49,14 +76,13 @@ Each Web and CLI operation writes a single-line JSON event to stdout with an ISO
docker compose logs -f app
```

API responses include an `X-Request-Id` header. Valid IDs supplied by callers are reused; invalid or missing IDs are replaced with a generated ID. To exercise a CLI read against the database, run:
API responses include an `X-Request-Id` header. Valid IDs supplied by callers are reused; invalid or missing IDs are replaced with a generated ID. To inspect the CLI API and database logs, run:

```sh
docker compose --profile cli up --build cli
docker compose --profile cli logs cli
docker compose logs -f app
```

The CLI reports whether the configured administrator exists. Its `api` and `db` events share a request ID and are retained in the Compose service logs.
The protected `status` command produces one `api` event and one `db` event with `client` set to `cli` and the same `request_id`. Credentials and tokens are never included in these logs.

## Run tests

Expand Down

This file was deleted.

118 changes: 118 additions & 0 deletions backend/src/main/java/com/findmypacks/cli/CliApplication.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
package com.findmypacks.cli;

import java.io.IOException;
import java.io.PrintStream;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;

public class CliApplication {
private static final String HELP = """
FindMyPacks CLI
Usage: docker compose --profile cli run --rm cli <command> [options]

Commands:
help Show this help
health Check API availability
token <username> <password> Obtain a Bearer token
status Check the authenticated user

Environment:
FINDMYPACKS_API_URL API URL (default: http://app:8080)
FINDMYPACKS_TOKEN Bearer token used by protected commands
""";

public static void main(String[] args) {
new CliApplication().run(args, System.getenv(), System.out, System.err, HttpClient.newHttpClient());
}

int run(String[] args, Map<String, String> environment, PrintStream out, PrintStream err, HttpClient client) {
String command = args.length == 0 ? "help" : args[0];
String baseUrl = environment.getOrDefault("FINDMYPACKS_API_URL", "http://app:8080");
try {
return switch (command) {
case "help", "--help", "-h" -> printHelp(out);
case "health" -> request(client, request(baseUrl + "/api/cli/health", null, null), out, err);
case "token" -> token(args, baseUrl, client, out, err);
case "status" -> protectedStatus(environment, baseUrl, client, out, err);
default -> unknown(command, err);
};
} catch (IOException exception) {
err.println("Unable to connect to the API: " + exception.getMessage());
return 1;
} catch (InterruptedException exception) {
Thread.currentThread().interrupt();
err.println("API request interrupted");
return 1;
}
}

private int printHelp(PrintStream out) {
out.print(HELP);
return 0;
}

private int token(String[] args, String baseUrl, HttpClient client, PrintStream out, PrintStream err)
throws IOException, InterruptedException {
if (args.length != 3) {
err.println("Usage: token <username> <password>");
return 2;
}
String body = "{\"username\":\"" + json(args[1]) + "\",\"password\":\"" + json(args[2]) + "\"}";
return request(client, request(baseUrl + "/api/cli/token", body, null), out, err);
}

private int protectedStatus(
Map<String, String> environment,
String baseUrl,
HttpClient client,
PrintStream out,
PrintStream err) throws IOException, InterruptedException {
String token = environment.get("FINDMYPACKS_TOKEN");
if (token == null || token.isBlank()) {
err.println("Set FINDMYPACKS_TOKEN before using protected commands");
return 2;
}
return request(client, request(baseUrl + "/api/cli/status", null, token), out, err);
}

private HttpRequest request(String url, String body, String token) {
HttpRequest.Builder request = HttpRequest.newBuilder(URI.create(url))
.header("Accept", "application/json");
if (token != null) {
request.header("Authorization", "Bearer " + token);
}
if (body == null) {
request.GET();
} else {
request.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body));
}
return request.build();
}

private int request(
HttpClient client,
HttpRequest request,
PrintStream out,
PrintStream err) throws IOException, InterruptedException {
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() >= 200 && response.statusCode() < 300) {
out.println(response.body());
return 0;
}
err.println("API returned HTTP " + response.statusCode() + ": " + response.body());
return 1;
}

private int unknown(String command, PrintStream err) {
err.println("Unknown command: " + command + ". Run 'help' to list commands.");
return 2;
}

private String json(String value) {
return value.replace("\\", "\\\\").replace("\"", "\\\"");
}
}
124 changes: 124 additions & 0 deletions backend/src/main/java/com/findmypacks/cli/CliController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.findmypacks.cli;

import com.findmypacks.user.AppUserDataAccess;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import java.io.IOException;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.filter.OncePerRequestFilter;

@RestController
@RequestMapping("/api/cli")
public class CliController {
private final AuthenticationManager authenticationManager;
private final TokenRegistry tokens;
private final AppUserDataAccess users;

public CliController(AuthenticationManager authenticationManager, TokenRegistry tokens, AppUserDataAccess users) {
this.authenticationManager = authenticationManager;
this.tokens = tokens;
this.users = users;
}

@GetMapping("/health")
public Map<String, String> health() {
return Map.of("status", "UP");
}

@PostMapping("/token")
public TokenResponse token(@Valid @RequestBody TokenRequest body) {
Authentication authentication = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(body.username(), body.password()));
return new TokenResponse(tokens.issue(authentication.getName()), "Bearer");
}

@GetMapping("/status")
public StatusResponse status(Authentication authentication) {
boolean exists = users.findByUsername(authentication.getName()).isPresent();
return new StatusResponse(authentication.getName(), exists ? "ACTIVE" : "MISSING");
}

public record TokenRequest(@NotBlank String username, @NotBlank String password) {
}

public record TokenResponse(String token, String type) {
}

public record StatusResponse(String username, String status) {
}

@Component
public static class TokenRegistry {
private final Map<String, String> tokens = new ConcurrentHashMap<>();

String issue(String username) {
String token = UUID.randomUUID().toString();
tokens.put(token, username);
return token;
}

Optional<String> usernameFor(String token) {
return Optional.ofNullable(tokens.get(token));
}
}

@Component
public static class BearerFilter extends OncePerRequestFilter {
private final TokenRegistry tokens;

public BearerFilter(TokenRegistry tokens) {
this.tokens = tokens;
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
String path = request.getRequestURI();
return !path.startsWith("/api/cli/")
|| path.equals("/api/cli/health")
|| path.equals("/api/cli/token");
}

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String authorization = request.getHeader("Authorization");
if (authorization != null && authorization.startsWith("Bearer ")) {
tokens.usernameFor(authorization.substring(7)).ifPresent(username ->
SecurityContextHolder.getContext().setAuthentication(
UsernamePasswordAuthenticationToken.authenticated(username, null, java.util.List.of())));
}
chain.doFilter(request, response);
}
}
}

@RestControllerAdvice(assignableTypes = CliController.class)
class CliExceptionHandler {
@ExceptionHandler(AuthenticationException.class)
ResponseEntity<Map<String, String>> invalidCredentials() {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of("message", "Invalid username or password"));
}
}
10 changes: 8 additions & 2 deletions backend/src/main/java/com/findmypacks/config/SecurityConfig.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.findmypacks.config;

import com.findmypacks.cli.CliController.BearerFilter;
import com.findmypacks.user.AppUserDataAccess;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
Expand All @@ -15,6 +16,7 @@
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AnonymousAuthenticationFilter;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
Expand All @@ -26,23 +28,27 @@ public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(
HttpSecurity http,
SecurityContextRepository securityContextRepository) throws Exception {
SecurityContextRepository securityContextRepository,
BearerFilter bearerFilter) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()))
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
.ignoringRequestMatchers("/api/cli/**"))
.securityContext(context -> context
.securityContextRepository(securityContextRepository))
.authorizeHttpRequests(authorize -> authorize
.requestMatchers(HttpMethod.GET, "/", "/index.html", "/assets/**", "/favicon.ico").permitAll()
.requestMatchers("/api/web/health", "/api/web/csrf", "/api/web/login").permitAll()
.requestMatchers("/api/cli/health", "/api/cli/token").permitAll()
.anyRequest().authenticated())
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.logout(AbstractHttpConfigurer::disable)
.exceptionHandling(exceptions -> exceptions
.authenticationEntryPoint((request, response, exception) ->
response.sendError(401)));
http.addFilterBefore(bearerFilter, AnonymousAuthenticationFilter.class);
return http.build();
}

Expand Down
Loading
Loading