178 lines
7.1 KiB
Markdown
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.
|