revsied documentation according to progress made

This commit is contained in:
2026-05-12 20:23:39 +02:00
parent 4aa3d0134c
commit 83427b4f6b
+77 -202
View File
@@ -1,168 +1,121 @@
**vaessl: Login Architecture** **vaessl: Login Architecture**
# Backend # Backend
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance will be used as good as possible to keep refactorings to a minimum. The first app to bridge will be Homebox which uses a classic username, password and Bearer token login proces to authenticate calls to its api. The second hypothetic app will be WikiJs which simply uses a user generated api key. The abstraction of the Java classes will try to cater to both authentication methods.
The login architecture is designed to make future additions to this bridging app as frictionless as possible. Abstraction and inheritance are used to keep refactorings to a minimum. The first app to bridge is Homebox, which uses a classic username/password and Bearer token login process. The second hypothetical app, WikiJS, uses a user-generated API key. The abstractions cater to both authentication methods.
## API Endpoints
The server context path is `/api`. The three endpoints that drive the connection flow:
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/api/login` | Authenticate a service; stores connection ID in session |
| `GET` | `/api/connections/status` | List connected services for the current session |
| `DELETE` | `/api/connections/{serviceType}` | Remove a service from the session; invalidates the session if no connections remain |
## Session Management
Authentication state is held server-side in a JDBC-backed Spring Session (stored in the same PostgreSQL database). After a successful login the controller stores `{serviceType}_CONNECTION_ID` in the `HttpSession`. The session cookie is `HttpOnly`, `SameSite=Lax`, and set to max age.
`CorsConfig` allows credentials from the two configured origins (`FRONTEND_LOCAL_URL`, `FRONTEND_PUBLIC_URL`). `allowCredentials(true)` is required for the session cookie to travel cross-origin.
## Single Table Inheritance ## Single Table Inheritance
The database entities will follow the Single Table Inheritance concept.
The database will have one "connections" table that has all the columnns of every supported app. So in this case the table will have username, password and attachment token which are specific to Homebox. Both Homebox and WikiJs will share the token field. Database entities use Single Table Inheritance — one `connections` table with a `service_type` discriminator column and all app-specific nullable columns. A unique constraint on `(appUrl, username)` prevents duplicate entries per user per instance.
The entities will be organized with an abstract ConnectionEntitiy class containing the id and appUrl and the app specific entities like HomeboxEntitiy:
***ConnectionEntitiy.java*** ***ConnectionEntity.java***
```
package com.vaessl.app.connection;
import jakarta.persistence.DiscriminatorColumn;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Inheritance;
import jakarta.persistence.InheritanceType;
import jakarta.persistence.Table;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
```java
@Entity @Entity
@Table(name = "connections") @Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
@Inheritance(strategy = InheritanceType.SINGLE_TABLE) @Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type") @DiscriminatorColumn(name = "service_type")
@Getter
@Setter
@NoArgsConstructor
public abstract class ConnectionEntity { public abstract class ConnectionEntity {
@Id @Id @GeneratedValue(strategy = GenerationType.IDENTITY)
@GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
Long id;
private String appUrl; private String appUrl;
private String username;
} }
``` ```
***HomeboxEntitiy.java*** ***HomeboxEntity.java***
```
package com.vaessl.app.connection;
import java.time.Instant;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import jakarta.persistence.DiscriminatorValue;
import jakarta.persistence.Entity;
import lombok.Getter;
import lombok.Setter;
```java
@Entity @Entity
@DiscriminatorValue("HOMEBOX") @DiscriminatorValue("HOMEBOX")
@Getter
@Setter
public class HomeboxEntity extends ConnectionEntity { public class HomeboxEntity extends ConnectionEntity {
private String token; private String token;
private String attachmentToken; private String attachmentToken;
private Instant expiresAt; private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity(); HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl()); he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token()); he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken")); he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt()); he.setExpiresAt(response.expiresAt());
return he; return he;
} }
} }
``` ```
## The Provider Pattern (Logic Layer) ## The Provider Pattern (Logic Layer)
To keep the core business logic clean, the app uses a Provider Pattern. This separates how the app authenticates (the Specific) from what the system does with that info (the General).
- ConnectionProvider Interface: Defines the contract. Every new app (Homebox, WikiJS, etc.) must implement this interface to tell the system how to authenticate and how to map its specific API response into a database entity. Each integrated app implements `ConnectionProvider`. This separates how an app authenticates (the specific) from what the system does with that result (the general).
***ConnectionProvider.java*** ***ConnectionProvider.java***
```
package com.vaessl.app.connection;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
```java
public interface ConnectionProvider { public interface ConnectionProvider {
String getServiceType(); String getServiceType();
void checkCredentials(ConnectionRequest request);
ConnectionResponse authenticate(ConnectionRequest request); ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response); ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response); void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
default Instant getTokenExpiry(ConnectionEntity entity) {
return null;
}
} }
``` ```
***HomeboxConnectionProvider.java***
```
package com.vaessl.app.connection;
import java.time.Instant; - `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields)
import java.util.HashMap; - `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
import java.util.Map; - `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
import org.springframework.stereotype.Component; ***HomeBoxConnectionProvider.java***
import org.springframework.web.client.RestClient;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import static com.vaessl.app.connection.Endpoint.*;
```java
@Component @Component
public class HomeBoxConnectionProvider implements ConnectionProvider { public class HomeBoxConnectionProvider implements ConnectionProvider {
private final RestClient.Builder restClientBuilder; @Override
public String getServiceType() { return "HOMEBOX"; }
private final ConnectionRepository cRepository;
public HomeBoxConnectionProvider(RestClient.Builder restClientBuilder, ConnectionRepository cRepository) {
this.restClientBuilder = restClientBuilder;
this.cRepository = cRepository;
}
@Override @Override
public String getServiceType() { public void checkCredentials(ConnectionRequest request) {
return "HOMEBOX"; // throws EmptyCredentialsException if username or password is null
} }
@Override @Override
public ConnectionResponse authenticate(ConnectionRequest request) { public ConnectionResponse authenticate(ConnectionRequest request) {
Map<String, Object> homeboxPayload = Map.of("username", request.credentials().get("username"), // POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt
"password", request.credentials().get("password"), "stayLoggedIn",
request.stayLoggedIn());
HomeboxLoginResponse hbResponse = restClientBuilder.baseUrl(request.appUrl())
.build()
.post()
.uri(HOMEBOX_LOGIN.getValue())
.body(homeboxPayload)
.retrieve()
.body(HomeboxLoginResponse.class);
if (hbResponse == null) {
throw new IllegalStateException("Remote API returned an empty body for " + request.appUrl());
} }
Map<String, Object> attachmentToken = new HashMap<>(); @Override
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
attachmentToken.put("attachmentToken", hbResponse.attachmentToken()); return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
return new ConnectionResponse(hbResponse.token(), hbResponse.expiresAt(), attachmentToken);
} }
@Override @Override
@@ -172,131 +125,53 @@ public class HomeBoxConnectionProvider implements ConnectionProvider {
@Override @Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) { public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
// casts to HomeboxEntity and updates token, attachmentToken, expiresAt
if (existing instanceof HomeboxEntity hbE) {
hbE.setToken(response.token());
hbE.setExpiresAt(response.expiresAt());
hbE.setAttachmentToken(response.getExtraVar("attachmentToken"));
cRepository.save(hbE);
}
} }
private record HomeboxLoginResponse(String token, String attachmentToken, Instant expiresAt) { @Override
public Instant getTokenExpiry(ConnectionEntity entity) {
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
} }
} }
``` ```
- Provider Registry: The ConnectionService automatically detects all implementations of the provider interface and stores them in a map. When a login request comes in, the service simply looks up the correct provider by its "Service Type" string.
***ConnectionService.java*** ***ConnectionService.java***
```
package com.vaessl.app.connection;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import org.springframework.stereotype.Service;
import com.vaessl.app.dto.ConnectionRequest;
import com.vaessl.app.dto.ConnectionResponse;
import com.vaessl.app.exception.ProviderNotFoundException;
```java
@Service @Service
public class ConnectionService { public class ConnectionService {
private final Map<String, ConnectionProvider> providerRegistry; // auto-discovers all ConnectionProvider beans into a serviceType → provider map
private final ConnectionRepository cRepository;
public ConnectionService(List<ConnectionProvider> providers, ConnectionRepository cRepository) {
this.providerRegistry = providers.stream()
.collect(Collectors.toMap(ConnectionProvider::getServiceType, p -> p));
this.cRepository = cRepository;
}
public ConnectionResponse login(ConnectionRequest request) {
ConnectionProvider provider = providerRegistry.get(request.serviceType());
if (provider == null) {
throw new ProviderNotFoundException();
}
public LoginResult login(ConnectionRequest request) {
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request); ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
ConnectionEntity existing = cRepository.findByAppUrl(request.appUrl()); // insert or update, then return LoginResult(connectionId, expiresAt)
if (existing != null) {
provider.updateToRepository(existing, response);
} else {
ConnectionEntity newEntity = provider.connectionToEntity(request, response);
cRepository.save(newEntity);
} }
return response; public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
// looks up entity by id, calls provider.getTokenExpiry() to compute connected flag
} }
} }
``` ```
## Generic Data Exchange (The DTOs) ## DTOs
Since different apps return different types of data (e.g., Homebox returns an attachmentToken, but WikiJS might return a something else), I use a Generic Data Bridge to move information between the API and the Database.
- ConnectionRequest: A universal "envelope" containing common fields (appUrl, serviceType) and a flexible Map<String, String> for credentials. This allows one DTO to handle both username/password logins and API-key-only logins. ### Request / Internal
***ConnectionRequest.java*** - **`ConnectionRequest`** — fields: `appUrl`, `serviceType`, `username`, `password`, `apiKey`, `stayLoggedIn`. Typed fields (not a credentials map) let providers validate exactly what they need via `checkCredentials()`. `apiKey` covers future API-key-only providers (e.g. WikiJS).
``` - **`ConnectionResponse`** — internal DTO between provider and service: `token`, `expiresAt`, `Map<String, Object> extraResponseData`. The `getExtraVar(key)` helper safely extracts app-specific values (e.g. Homebox's `attachmentToken`).
package com.vaessl.app.dto;
import java.util.Map; ### Response (returned to frontend)
import com.fasterxml.jackson.annotation.JsonProperty; - **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session.
import jakarta.validation.constraints.NotBlank; - **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend.
import jakarta.validation.constraints.NotEmpty;
public record ConnectionRequest( - **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`.
@NotBlank(message = "App URL is mandatory") String appUrl,
@NotBlank(message = "Service type is mandatory") String serviceType,
@NotEmpty(message = "Credentials are mandatory") Map<String, String> credentials,
@JsonProperty(defaultValue = "false") Boolean stayLoggedIn) {
public ConnectionRequest { ## Supported Service Types
if (stayLoggedIn == null) {
stayLoggedIn = false;
}
}
}
```
- ConnectionResponse: A "Smart" DTO that holds the core authentication data (the token and expiresAt) and a Map<String, Object> called extraResponseData. Registered in the `ServiceType` enum. Currently: `HOMEBOX`. Adding a new service means implementing `ConnectionProvider` and adding the discriminator value — no changes to `ConnectionService` or `ConnectionController` required.
- The "Smart" Getter: The response object contains helper methods to safely extract app-specific variables from this map. This allows the system to be "Generic" while still giving specific entities (like HomeboxEntity) access to the unique data they need.
***ConnectionResponse.java***
```
package com.vaessl.app.dto;
import java.time.Instant;
import java.util.Map;
public record ConnectionResponse(String token, Instant expiresAt, Map<String, Object> extraResponseData) {
public String getExtraVar(String key) {
if(extraResponseData == null) {
return null;
} else {
Object value = extraResponseData.get(key);
return value != null ? String.valueOf(value) : null;
}
}
}
```