Files
Vaessl/docs/03-Architecture/01-Login-Architecture.md
T
2026-05-16 00:16:28 +02:00

178 lines
7.1 KiB
Markdown

**vaessl: Login Architecture**
# Backend
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
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.
***ConnectionEntity.java***
```java
@Entity
@Table(name = "connections", uniqueConstraints = { @UniqueConstraint(columnNames = { "appUrl", "username" }) })
@Inheritance(strategy = InheritanceType.SINGLE_TABLE)
@DiscriminatorColumn(name = "service_type")
public abstract class ConnectionEntity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String appUrl;
private String username;
}
```
***HomeboxEntity.java***
```java
@Entity
@DiscriminatorValue("HOMEBOX")
public class HomeboxEntity extends ConnectionEntity {
private String token;
private String attachmentToken;
private Instant expiresAt;
public static HomeboxEntity from(ConnectionRequest request, ConnectionResponse response) {
HomeboxEntity he = new HomeboxEntity();
he.setAppUrl(request.appUrl());
he.setUsername(request.username());
he.setToken(response.token());
he.setAttachmentToken(response.getExtraVar("attachmentToken"));
he.setExpiresAt(response.expiresAt());
return he;
}
}
```
## The Provider Pattern (Logic Layer)
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***
```java
public interface ConnectionProvider {
String getServiceType();
void checkCredentials(ConnectionRequest request);
ConnectionResponse authenticate(ConnectionRequest request);
ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request);
ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response);
void updateToRepository(ConnectionEntity existing, ConnectionResponse response);
default Instant getTokenExpiry(ConnectionEntity entity) {
return null;
}
}
```
- `checkCredentials` — validates that required fields are present before attempting the remote call (throws `EmptyCredentialsException` with a list of missing fields)
- `findUniqueConnectionEntry` — looks up an existing record to decide insert vs. update
- `getTokenExpiry` — default returns `null` (no expiry); token-based providers override this so `ConnectionService` can compute the `connected` flag
***HomeboxConnectionProvider.java***
```java
@Component
public class HomeboxConnectionProvider implements ConnectionProvider {
@Override
public String getServiceType() { return "HOMEBOX"; }
@Override
public void checkCredentials(ConnectionRequest request) {
// throws EmptyCredentialsException if username or password is null
}
@Override
public ConnectionResponse authenticate(ConnectionRequest request) {
// POST to Homebox /api/v1/users/login, returns token + attachmentToken + expiresAt
}
@Override
public ConnectionEntity findUniqueConnectionEntry(ConnectionRequest request) {
return cRepository.findByAppUrlAndUsername(request.appUrl(), request.username());
}
@Override
public ConnectionEntity connectionToEntity(ConnectionRequest request, ConnectionResponse response) {
return HomeboxEntity.from(request, response);
}
@Override
public void updateToRepository(ConnectionEntity existing, ConnectionResponse response) {
// casts to HomeboxEntity and updates token, attachmentToken, expiresAt
}
@Override
public Instant getTokenExpiry(ConnectionEntity entity) {
return (entity instanceof HomeboxEntity he) ? he.getExpiresAt() : null;
}
}
```
***ConnectionService.java***
```java
@Service
public class ConnectionService {
// auto-discovers all ConnectionProvider beans into a serviceType → provider map
public LoginResult login(ConnectionRequest request) {
provider.checkCredentials(request);
ConnectionResponse response = provider.authenticate(request);
ConnectionEntity existing = provider.findUniqueConnectionEntry(request);
// insert or update, then return LoginResult(connectionId, expiresAt)
}
public ConnectionStatusResponse getConnectionStatus(String serviceType, Long connectionId) {
// looks up entity by id, calls provider.getTokenExpiry() to compute connected flag
}
}
```
## DTOs
### Request / Internal
- **`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`).
### Response (returned to frontend)
- **`LoginResult`** — `connectionId`, `expiresAt`. Returned by `ConnectionService.login()` and used by the controller to populate the session.
- **`AuthResponse`** — `serviceType`, `expiresAt`. Returned by `POST /api/login` to the frontend.
- **`ConnectionStatusResponse`** — `serviceType`, `appUrl`, `username`, `expiresAt`, `connected`. Returned by `GET /api/connections/status`.
## Supported Service Types
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.