refactor(ext-oidc): replace auth modules with security extensions
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# flash-ext-security-core
|
||||
|
||||
Authentication and authorization for Flash, independent of any credential. Mechanisms —
|
||||
[`-oidc`](../../flash-ext-security-oidc/docs/README.md), [`-apikey`](../../flash-ext-security-apikey/docs/README.md),
|
||||
[`-form`](../../flash-ext-security-form/docs/README.md), or your own — register into one chain;
|
||||
this module owns everything downstream of "who is the caller".
|
||||
|
||||
```java
|
||||
app.install(new SecurityExtension()
|
||||
.users(principal -> users.findOrProvision(principal)) // optional: principal → your user
|
||||
.roles((identity, role, on) -> members.has(identity.user(User.class), role, on.get("project"))))
|
||||
.install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
|
||||
```
|
||||
|
||||
## The model
|
||||
|
||||
| Type | Role |
|
||||
|---|---|
|
||||
| `AuthenticationMechanism` | reads one kind of credential: returns a `Principal`, `null` (not mine), or throws `AuthenticationFailedException` (mine, invalid) |
|
||||
| `Principal` | who the mechanism proved the caller to be — typed per mechanism (`OidcPrincipal`, `ApiKeyPrincipal`, …) |
|
||||
| `SecurityIdentity` | the current caller: `principal(OidcPrincipal.class)`, `user(User.class)`, `hasRole`, `hasScope` |
|
||||
| `UserResolver` | principal → application user, resolved lazily, once per request |
|
||||
| `RoleResolver` | whether a caller holds a role, optionally on a resource |
|
||||
| `AuthenticationEntryPoint` | the answer to a request that needs a caller and carries no credential |
|
||||
|
||||
Mechanisms never write the response. That is what keeps the one mistake that matters impossible to
|
||||
make: a credential that was presented and rejected is always a 401, never a redirect into a sign-in
|
||||
page an API client cannot parse.
|
||||
|
||||
## Annotations
|
||||
|
||||
On a handler or an MCP tool class:
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| `@Authenticated` | any authenticated caller |
|
||||
| `@PermitAll` | anyone; a caller who authenticates is still identified, one who fails is anonymous |
|
||||
| `@RolesAllowed(value, on)` | any of the roles; `on` names the path/query parameters (tool arguments on MCP) identifying the resource |
|
||||
| `@ScopesAllowed(value)` | every one of the credential's scopes |
|
||||
|
||||
`@RolesAllowed(value = "MANAGER", on = "project")` on `/projects/{project}/keys` asks the
|
||||
`RoleResolver` whether the caller is a manager *of that project*. A handler declaring roles with no
|
||||
`RoleResolver` configured fails the boot. Policies compile once; checking one allocates nothing.
|
||||
|
||||
## The chain
|
||||
|
||||
Mechanisms are tried in registration order, then the session cookie. The first to return a
|
||||
principal wins. When none does:
|
||||
|
||||
- a browser (`Accept: text/html`) is redirected to the only login method, or to `loginPage` when there are several;
|
||||
- anything else gets `401` with every mechanism's challenge in `WWW-Authenticate`.
|
||||
|
||||
`entryPoint(...)` replaces that, e.g. to pick an identity provider from the user's email domain.
|
||||
|
||||
## Sessions
|
||||
|
||||
`signIn(req, res, principal[, expiresAt])` stores the principal under a `flash_session` cookie;
|
||||
`POST /auth/logout` ends it and follows `Principal.logoutUrl()`. An expired session is handed to the
|
||||
`SessionRefresher` registered for its principal type, or ended. `InMemorySessionStore` is the
|
||||
default; `sessions(...)` swaps it for one that survives a restart or spans instances.
|
||||
|
||||
`GET /auth/methods` lists every registered `LoginMethod` for a client to render.
|
||||
|
||||
## OpenAPI
|
||||
|
||||
With `flash-ext-openapi` present, every registered mechanism's `SecurityScheme` is published, and
|
||||
every protected operation lists them as alternatives, with its 401 and — for roles or scopes — its
|
||||
403 and what it requires. Nothing to write per mechanism.
|
||||
|
||||
## Writing a mechanism
|
||||
|
||||
```java
|
||||
security.mechanism(new AuthenticationMechanism() {
|
||||
public Principal authenticate(Request req) {
|
||||
String key = req.header("X-Key");
|
||||
if (key == null) return null; // not mine
|
||||
Principal p = keys.get(key);
|
||||
if (p == null) throw new AuthenticationFailedException(null); // mine, and invalid
|
||||
return p;
|
||||
}
|
||||
public SecurityScheme scheme() { return SecurityScheme.bearer("key", "opaque"); }
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
[`flash-ext-security-test`](../../flash-ext-security-test/docs/README.md) authenticates requests as
|
||||
any principal without an identity provider.
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project xmlns="http://maven.apache.org/POM/4.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
|
||||
<parent>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-extensions</artifactId>
|
||||
<version>2.1.0-SNAPSHOT</version>
|
||||
</parent>
|
||||
|
||||
<artifactId>flash-ext-security-core</artifactId>
|
||||
|
||||
<dependencies>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-ext-openapi</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.junit.jupiter</groupId>
|
||||
<artifactId>junit-jupiter</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>dev.relism</groupId>
|
||||
<artifactId>flash-testing</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
</project>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/** The handler or MCP tool requires an authenticated caller. */
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@java.lang.annotation.Target(ElementType.TYPE)
|
||||
public @interface Authenticated {}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
|
||||
/** Answers a request that needs a caller and carries no credential at all. */
|
||||
@FunctionalInterface
|
||||
public interface AuthenticationEntryPoint {
|
||||
|
||||
Object commence(Request req, Response res) throws Exception;
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
|
||||
/** A credential was presented and rejected. Stackless: turning away forged tokens stays cheap. */
|
||||
public final class AuthenticationFailedException extends HttpException {
|
||||
|
||||
private final String challenge;
|
||||
|
||||
/** @param challenge the {@code WWW-Authenticate} value to answer with, or {@code null} */
|
||||
public AuthenticationFailedException(String challenge) {
|
||||
super(401, "Unauthorized");
|
||||
this.challenge = challenge;
|
||||
}
|
||||
|
||||
public String challenge() {
|
||||
return challenge;
|
||||
}
|
||||
|
||||
@Override
|
||||
public synchronized Throwable fillInStackTrace() {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* Reads one kind of credential off a request. A mechanism never writes the response: an anonymous
|
||||
* request is answered by the {@link AuthenticationEntryPoint}, a rejected one by the 401 its
|
||||
* {@link AuthenticationFailedException} carries.
|
||||
*/
|
||||
public interface AuthenticationMechanism {
|
||||
|
||||
/**
|
||||
* The caller, or {@code null} when the request carries no credential of this kind.
|
||||
*
|
||||
* @throws AuthenticationFailedException the request carries one, and it is invalid
|
||||
*/
|
||||
Principal authenticate(Request req);
|
||||
|
||||
/** How OpenAPI documents the credential and a 401 challenges for it; {@code null} for neither. */
|
||||
default SecurityScheme scheme() { return null; }
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/** One instance's sessions, lost on restart. Expired ones are swept whenever a session is saved. */
|
||||
public final class InMemorySessionStore implements SessionStore {
|
||||
|
||||
private final ConcurrentHashMap<String, Session> sessions = new ConcurrentHashMap<>();
|
||||
|
||||
@Override
|
||||
public void save(Session session) {
|
||||
long now = System.currentTimeMillis();
|
||||
sessions.values().removeIf(s -> s.expiresAt().toEpochMilli() <= now);
|
||||
sessions.put(session.id(), session);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Session find(String id) {
|
||||
return sessions.get(id);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void delete(String id) {
|
||||
sessions.remove(id);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/** A way to sign in, listed at {@code GET /auth/methods} for a client to offer. */
|
||||
public record LoginMethod(String id, String name, String url, Kind kind) {
|
||||
|
||||
public enum Kind {
|
||||
/** A browser navigates to {@code url} and comes back signed in — OpenID Connect, for instance. */
|
||||
REDIRECT,
|
||||
/** A client posts {@code username} and {@code password} to {@code url}. */
|
||||
FORM
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/**
|
||||
* Anyone may call the handler. A caller who authenticates is still identified; one whose credential
|
||||
* is rejected is treated as anonymous rather than refused.
|
||||
*/
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@java.lang.annotation.Target(ElementType.TYPE)
|
||||
public @interface PermitAll {}
|
||||
+20
@@ -0,0 +1,20 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/**
|
||||
* Who an {@link AuthenticationMechanism} proved the caller to be. Each mechanism has its own type;
|
||||
* {@link SecurityIdentity#principal(Class)} reads it back.
|
||||
*/
|
||||
public interface Principal {
|
||||
|
||||
/** Unique within the mechanism that produced it. */
|
||||
String name();
|
||||
|
||||
/** Whether the credential grants {@code scope}. One that carries no scopes grants every scope. */
|
||||
default boolean hasScope(String scope) { return true; }
|
||||
|
||||
/** Whether the credential was issued for {@code audience} (RFC 8707). One bound to no audience was. */
|
||||
default boolean hasAudience(String audience) { return true; }
|
||||
|
||||
/** Where signing out sends the browser; {@code null} for the application root. */
|
||||
default String logoutUrl() { return null; }
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/**
|
||||
* Whether a caller holds a role — read from a token, a database, anywhere. {@code on} identifies
|
||||
* the resource for roles held per resource rather than globally.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface RoleResolver {
|
||||
|
||||
boolean hasRole(SecurityIdentity identity, String role, Target on);
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/**
|
||||
* The caller must hold at least one of these roles, as decided by the configured
|
||||
* {@link RoleResolver}. Implies {@link Authenticated}.
|
||||
*/
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@java.lang.annotation.Target(ElementType.TYPE)
|
||||
public @interface RolesAllowed {
|
||||
|
||||
String[] value();
|
||||
|
||||
/**
|
||||
* Names of the path or query parameters (tool arguments on MCP) that identify the resource the
|
||||
* role is held on — {@code on = "project"} checks the role on {@code /projects/{project}}.
|
||||
*/
|
||||
String[] on() default {};
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.lang.annotation.Documented;
|
||||
import java.lang.annotation.ElementType;
|
||||
import java.lang.annotation.Retention;
|
||||
import java.lang.annotation.RetentionPolicy;
|
||||
|
||||
/** The caller's credential must grant every one of these scopes. Implies {@link Authenticated}. */
|
||||
@Documented
|
||||
@Retention(RetentionPolicy.RUNTIME)
|
||||
@java.lang.annotation.Target(ElementType.TYPE)
|
||||
public @interface ScopesAllowed {
|
||||
|
||||
String[] value();
|
||||
}
|
||||
+321
@@ -0,0 +1,321 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.exceptions.HttpException;
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributor;
|
||||
import dev.relism.flash.ext.openapi.OpenApiContributorRegistry;
|
||||
import dev.relism.flash.ext.openapi.OpenApiOperationContribution;
|
||||
import dev.relism.flash.ext.openapi.OpenApiResponseContribution;
|
||||
import dev.relism.flash.extension.FlashContext;
|
||||
import dev.relism.flash.extension.FlashExtension;
|
||||
import dev.relism.flash.extension.FlashRegistrar;
|
||||
import dev.relism.flash.http.ContentType;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.Middleware;
|
||||
import dev.relism.flash.routing.MiddlewareKey;
|
||||
import dev.relism.flash.routing.MiddlewareNode;
|
||||
import dev.relism.fpr.core.ByteView;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.SecureRandom;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.Arrays;
|
||||
import java.util.Base64;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* Flash security: the authentication chain, the policies security annotations declare, sessions,
|
||||
* and the {@code /auth/logout} and {@code /auth/methods} routes. Mechanisms register through
|
||||
* {@link #mechanism}, directly or from their own extensions, and are tried in registration order
|
||||
* before the session cookie.
|
||||
*
|
||||
* <pre>{@code
|
||||
* app.install(new SecurityExtension().users(users).roles(roles))
|
||||
* .install(new OidcExtension(OidcProvider.of("sso", issuer, clientId, secret)));
|
||||
* }</pre>
|
||||
*/
|
||||
public class SecurityExtension implements FlashExtension {
|
||||
|
||||
/** The node security annotations mount under, for middleware that must run before or after it. */
|
||||
public static final MiddlewareKey POLICY = MiddlewareKey.of("flash.security.policy");
|
||||
|
||||
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
|
||||
private static final String COOKIE = "flash_session";
|
||||
private static final SecureRandom RANDOM = new SecureRandom();
|
||||
|
||||
private final Map<Class<?>, SessionRefresher> refreshers = new ConcurrentHashMap<>();
|
||||
private volatile AuthenticationMechanism[] mechanisms = {};
|
||||
private volatile SecurityScheme[] schemes = {};
|
||||
private volatile LoginMethod[] loginMethods = {};
|
||||
private volatile String challenges;
|
||||
private volatile String methodsJson = "[]";
|
||||
|
||||
UserResolver<?> users = principal -> principal;
|
||||
RoleResolver roles;
|
||||
private AuthenticationEntryPoint entryPoint = this::commence;
|
||||
private SessionStore sessions = new InMemorySessionStore();
|
||||
private Duration sessionTimeout = Duration.ofHours(12);
|
||||
private String loginPage = "/login";
|
||||
|
||||
// -- Configuration --------------------------------------------------------
|
||||
|
||||
/** Resolves {@link SecurityIdentity#user} — default: the principal itself. */
|
||||
public SecurityExtension users(UserResolver<?> users) {
|
||||
this.users = users;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Required by {@link RolesAllowed}; a handler that declares roles without one fails the boot. */
|
||||
public SecurityExtension roles(RoleResolver roles) {
|
||||
this.roles = roles;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Replaces the default: a browser is redirected to sign in, anything else gets 401 with every challenge. */
|
||||
public SecurityExtension entryPoint(AuthenticationEntryPoint entryPoint) {
|
||||
this.entryPoint = entryPoint;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SecurityExtension sessions(SessionStore sessions) {
|
||||
this.sessions = sessions;
|
||||
return this;
|
||||
}
|
||||
|
||||
public SecurityExtension sessionTimeout(Duration sessionTimeout) {
|
||||
this.sessionTimeout = sessionTimeout;
|
||||
return this;
|
||||
}
|
||||
|
||||
/** Where a browser signs in, unless the only {@link LoginMethod} is a redirect it can follow directly. */
|
||||
public SecurityExtension loginPage(String loginPage) {
|
||||
this.loginPage = loginPage;
|
||||
return this;
|
||||
}
|
||||
|
||||
// -- Registration (boot time) ---------------------------------------------
|
||||
|
||||
public synchronized SecurityExtension mechanism(AuthenticationMechanism mechanism) {
|
||||
mechanisms = append(mechanisms, mechanism);
|
||||
return mechanism.scheme() == null ? this : scheme(mechanism.scheme());
|
||||
}
|
||||
|
||||
/** Documents and challenges for a credential beyond the one {@link AuthenticationMechanism#scheme()} names. */
|
||||
public synchronized SecurityExtension scheme(SecurityScheme scheme) {
|
||||
schemes = append(schemes, scheme);
|
||||
challenges = challenges == null ? scheme.challenge() : challenges + ", " + scheme.challenge();
|
||||
return this;
|
||||
}
|
||||
|
||||
public synchronized SecurityExtension loginMethod(LoginMethod method) {
|
||||
loginMethods = append(loginMethods, method);
|
||||
StringBuilder json = new StringBuilder("[");
|
||||
for (LoginMethod m : loginMethods) {
|
||||
if (json.length() > 1) json.append(',');
|
||||
json.append("{\"id\":\"").append(m.id()).append("\",\"name\":\"").append(m.name())
|
||||
.append("\",\"url\":\"").append(m.url()).append("\",\"kind\":\"").append(m.kind().name().toLowerCase()).append("\"}");
|
||||
}
|
||||
methodsJson = json.append(']').toString();
|
||||
return this;
|
||||
}
|
||||
|
||||
public SecurityExtension refresher(Class<? extends Principal> type, SessionRefresher refresher) {
|
||||
refreshers.put(type, refresher);
|
||||
return this;
|
||||
}
|
||||
|
||||
/** The schemes of every registered mechanism, in registration order. */
|
||||
public List<SecurityScheme> schemes() {
|
||||
return List.of(schemes);
|
||||
}
|
||||
|
||||
// -- Runtime --------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* The caller, or {@code null} when no mechanism recognises a credential.
|
||||
*
|
||||
* @throws AuthenticationFailedException a mechanism recognised one and rejected it
|
||||
*/
|
||||
public SecurityIdentity authenticate(Request req) {
|
||||
for (AuthenticationMechanism mechanism : mechanisms) {
|
||||
Principal principal = mechanism.authenticate(req);
|
||||
if (principal != null) return new SecurityIdentity(principal, this, req);
|
||||
}
|
||||
Principal principal = sessionPrincipal(req);
|
||||
return principal == null ? null : new SecurityIdentity(principal, this, req);
|
||||
}
|
||||
|
||||
/**
|
||||
* The policy {@code type}'s annotations declare, checked against this configuration — declaring
|
||||
* roles without a {@link RoleResolver} fails here, at boot. {@code null} for no annotations.
|
||||
*/
|
||||
public SecurityPolicy policy(Class<?> type) {
|
||||
SecurityPolicy policy = SecurityPolicy.of(type);
|
||||
if (policy != null && policy.requiresRoles() && roles == null) {
|
||||
throw new IllegalStateException(type.getName() + " declares @RolesAllowed, but no RoleResolver is configured — SecurityExtension.roles(...)");
|
||||
}
|
||||
return policy;
|
||||
}
|
||||
|
||||
public Middleware enforce(SecurityPolicy policy) {
|
||||
return enforce(policy, entryPoint);
|
||||
}
|
||||
|
||||
/** {@code anonymous} answers a caller without credentials on this route instead of the configured entry point. */
|
||||
public Middleware enforce(SecurityPolicy policy, AuthenticationEntryPoint anonymous) {
|
||||
return next -> (req, res) -> {
|
||||
SecurityIdentity identity;
|
||||
try {
|
||||
identity = authenticate(req);
|
||||
} catch (AuthenticationFailedException rejected) {
|
||||
if (policy.required) {
|
||||
if (rejected.challenge() != null) res.header(WWW_AUTHENTICATE, rejected.challenge());
|
||||
throw rejected;
|
||||
}
|
||||
identity = null;
|
||||
}
|
||||
if (identity == null) {
|
||||
if (policy.required) return anonymous.commence(req, res);
|
||||
} else if (!policy.permitsScopes(identity)) {
|
||||
res.header(WWW_AUTHENTICATE, policy.scopeChallenge);
|
||||
throw HttpException.forbidden();
|
||||
} else if (!policy.permitsRoles(identity, policy.on.length == 0 ? Target.NONE : name -> {
|
||||
String value = req.param(name);
|
||||
return value != null ? value : req.query(name);
|
||||
})) {
|
||||
throw HttpException.forbidden();
|
||||
}
|
||||
SecurityIdentity.CURRENT.set(identity);
|
||||
try {
|
||||
return next.handle(req, res);
|
||||
} finally {
|
||||
SecurityIdentity.CURRENT.remove();
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts a session for {@code principal} lasting the configured timeout. */
|
||||
public void signIn(Request req, Response res, Principal principal) {
|
||||
signIn(req, res, principal, Instant.now().plus(sessionTimeout));
|
||||
}
|
||||
|
||||
public void signIn(Request req, Response res, Principal principal, Instant expiresAt) {
|
||||
byte[] id = new byte[24];
|
||||
RANDOM.nextBytes(id);
|
||||
Session session = new Session(Base64.getUrlEncoder().withoutPadding().encodeToString(id), principal, expiresAt);
|
||||
sessions.save(session);
|
||||
res.header("Set-Cookie", COOKIE + "=" + session.id() + "; Path=/; HttpOnly; SameSite=Lax" + (req.origin().startsWith("https") ? "; Secure" : ""));
|
||||
}
|
||||
|
||||
/** Ends the caller's session and returns where the browser goes next. */
|
||||
public String signOut(Request req, Response res) {
|
||||
String id = req.cookie(COOKIE);
|
||||
Session session = id == null ? null : sessions.find(id);
|
||||
if (session != null) sessions.delete(id);
|
||||
res.header("Set-Cookie", COOKIE + "=; Path=/; Max-Age=0; HttpOnly; SameSite=Lax");
|
||||
String next = session == null ? null : session.principal().logoutUrl();
|
||||
return next == null ? "/" : next;
|
||||
}
|
||||
|
||||
// -- Extension ------------------------------------------------------------
|
||||
|
||||
@Override
|
||||
public void configure(FlashRegistrar<?> app, FlashContext ctx) {
|
||||
ctx.provide(SecurityExtension.class, this);
|
||||
ctx.addAnnotationProcessor(handler -> {
|
||||
SecurityPolicy policy = policy(handler);
|
||||
return policy == null ? List.of() : List.of(MiddlewareNode.of(POLICY, enforce(policy)));
|
||||
});
|
||||
app.post("/auth/logout", (req, res) -> {
|
||||
res.status(303).header("Location", signOut(req, res));
|
||||
return null;
|
||||
});
|
||||
app.get("/auth/methods", (req, res) -> {
|
||||
res.type(ContentType.JSON);
|
||||
return methodsJson;
|
||||
});
|
||||
ctx.onReady(() -> {
|
||||
try {
|
||||
OpenApi.register(ctx, this);
|
||||
} catch (NoClassDefFoundError absent) {
|
||||
// flash-ext-openapi is not on the classpath
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private Object commence(Request req, Response res) {
|
||||
LoginMethod[] methods = loginMethods;
|
||||
String accept = req.header("Accept");
|
||||
if (methods.length > 0 && accept != null && accept.contains("text/html")) {
|
||||
String login = methods.length == 1 && methods[0].kind() == LoginMethod.Kind.REDIRECT ? methods[0].url() : loginPage;
|
||||
ByteView query = req.getRequestLine().getQuery();
|
||||
byte[] raw = new byte[query == null ? 0 : query.length()];
|
||||
for (int i = 0; i < raw.length; i++) raw[i] = query.byteAt(i);
|
||||
String target = raw.length == 0 ? req.path() : req.path() + "?" + new String(raw, StandardCharsets.UTF_8);
|
||||
res.redirect(login + "?redirect=" + URLEncoder.encode(target, StandardCharsets.UTF_8));
|
||||
return null;
|
||||
}
|
||||
if (challenges != null) res.header(WWW_AUTHENTICATE, challenges);
|
||||
throw HttpException.unauthorized();
|
||||
}
|
||||
|
||||
private Principal sessionPrincipal(Request req) {
|
||||
String id = req.cookie(COOKIE);
|
||||
Session session = id == null ? null : sessions.find(id);
|
||||
if (session == null) return null;
|
||||
if (session.expiresAt().toEpochMilli() > System.currentTimeMillis()) return session.principal();
|
||||
SessionRefresher refresher = refreshers.get(session.principal().getClass());
|
||||
Session renewed = refresher == null ? null : refresher.refresh(session);
|
||||
if (renewed == null) {
|
||||
sessions.delete(id);
|
||||
return null;
|
||||
}
|
||||
sessions.save(renewed);
|
||||
return renewed.principal();
|
||||
}
|
||||
|
||||
private static <T> T[] append(T[] array, T element) {
|
||||
T[] grown = Arrays.copyOf(array, array.length + 1);
|
||||
grown[array.length] = element;
|
||||
return grown;
|
||||
}
|
||||
|
||||
/** Isolated so this extension loads without flash-ext-openapi on the classpath. */
|
||||
private static final class OpenApi {
|
||||
|
||||
static void register(FlashContext ctx, SecurityExtension security) {
|
||||
ctx.find(OpenApiContributorRegistry.class).ifPresent(registry -> registry.add(new OpenApiContributor() {
|
||||
@Override
|
||||
public Map<String, Object> componentContributions() {
|
||||
Map<String, Object> definitions = new LinkedHashMap<>();
|
||||
for (SecurityScheme scheme : security.schemes) definitions.put(scheme.name(), scheme.definition());
|
||||
return definitions.isEmpty() ? Map.of() : Map.of("securitySchemes", definitions);
|
||||
}
|
||||
|
||||
@Override
|
||||
public OpenApiOperationContribution operationFor(Class<?> handler) {
|
||||
SecurityPolicy policy = SecurityPolicy.of(handler);
|
||||
if (policy == null || !policy.required) return OpenApiOperationContribution.empty();
|
||||
OpenApiOperationContribution.Builder operation = OpenApiOperationContribution.builder();
|
||||
for (SecurityScheme scheme : security.schemes) {
|
||||
operation.security(scheme.name(), scheme.issuer() != null ? List.of(policy.scopes) : List.of());
|
||||
}
|
||||
operation.response(401, OpenApiResponseContribution.of("Authentication required"));
|
||||
String roles = policy.roles.length == 0 ? null : "Requires role " + String.join(" or ", policy.roles)
|
||||
+ (policy.on.length == 0 ? "" : " on " + String.join(", ", policy.on));
|
||||
String scopes = policy.scopes.length == 0 ? null : "Requires scopes " + String.join(" ", policy.scopes);
|
||||
if (roles != null || scopes != null) {
|
||||
operation.response(403, OpenApiResponseContribution.of(
|
||||
roles == null ? scopes : scopes == null ? roles : roles + "; " + scopes));
|
||||
}
|
||||
return operation.build();
|
||||
}
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.models.Request;
|
||||
|
||||
/**
|
||||
* The authenticated caller of the current request: the {@link Principal} a mechanism produced, the
|
||||
* application user it resolves to, and the roles and scopes it holds.
|
||||
*/
|
||||
public final class SecurityIdentity {
|
||||
|
||||
static final ThreadLocal<SecurityIdentity> CURRENT = new ThreadLocal<>();
|
||||
|
||||
private final Principal principal;
|
||||
private final SecurityExtension security;
|
||||
private final Request request;
|
||||
private Object user;
|
||||
|
||||
SecurityIdentity(Principal principal, SecurityExtension security, Request request) {
|
||||
this.principal = principal;
|
||||
this.security = security;
|
||||
this.request = request;
|
||||
}
|
||||
|
||||
/** The caller of the request this thread is handling; {@code null} when it is anonymous. */
|
||||
public static SecurityIdentity current() {
|
||||
return CURRENT.get();
|
||||
}
|
||||
|
||||
public Principal principal() {
|
||||
return principal;
|
||||
}
|
||||
|
||||
/**
|
||||
* The request being authorized. {@link Target} carries what {@link RolesAllowed#on()} names, read
|
||||
* from path and query parameters; a {@link RoleResolver} whose scope is somewhere else — a tenant
|
||||
* header, say — reads it from here.
|
||||
*/
|
||||
public Request request() {
|
||||
return request;
|
||||
}
|
||||
|
||||
/** The principal as {@code type}, or {@code null} when another mechanism authenticated the caller. */
|
||||
public <P extends Principal> P principal(Class<P> type) {
|
||||
return type.isInstance(principal) ? type.cast(principal) : null;
|
||||
}
|
||||
|
||||
/** The application user, resolved once per request by the configured {@link UserResolver}. */
|
||||
public <U> U user(Class<U> type) {
|
||||
if (user == null) user = security.users.resolve(principal);
|
||||
return type.cast(user);
|
||||
}
|
||||
|
||||
public boolean hasScope(String scope) {
|
||||
return principal.hasScope(scope);
|
||||
}
|
||||
|
||||
public boolean hasRole(String role) {
|
||||
return hasRole(role, Target.NONE);
|
||||
}
|
||||
|
||||
public boolean hasRole(String role, Target on) {
|
||||
if (security.roles == null) throw new IllegalStateException("No RoleResolver configured — SecurityExtension.roles(...)");
|
||||
return security.roles.hasRole(this, role, on);
|
||||
}
|
||||
}
|
||||
+63
@@ -0,0 +1,63 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/** What a handler's or tool's security annotations require, compiled once at boot. Checks allocate nothing. */
|
||||
public final class SecurityPolicy {
|
||||
|
||||
/** Any authenticated caller. */
|
||||
public static final SecurityPolicy AUTHENTICATED = new SecurityPolicy(true, new String[0], new String[0], new String[0]);
|
||||
|
||||
final boolean required;
|
||||
final String[] roles;
|
||||
final String[] on;
|
||||
final String[] scopes;
|
||||
final String scopeChallenge;
|
||||
|
||||
private SecurityPolicy(boolean required, String[] roles, String[] on, String[] scopes) {
|
||||
this.required = required;
|
||||
this.roles = roles;
|
||||
this.on = on;
|
||||
this.scopes = scopes;
|
||||
this.scopeChallenge = "Bearer error=\"insufficient_scope\", scope=\"" + String.join(" ", scopes) + "\"";
|
||||
}
|
||||
|
||||
/** The policy {@code type} declares, or {@code null} when it carries no security annotation. */
|
||||
public static SecurityPolicy of(Class<?> type) {
|
||||
boolean permitAll = type.isAnnotationPresent(PermitAll.class);
|
||||
boolean authenticated = type.isAnnotationPresent(Authenticated.class);
|
||||
RolesAllowed roles = type.getAnnotation(RolesAllowed.class);
|
||||
ScopesAllowed scopes = type.getAnnotation(ScopesAllowed.class);
|
||||
if (!permitAll && !authenticated && roles == null && scopes == null) return null;
|
||||
if (permitAll && (authenticated || roles != null || scopes != null)) {
|
||||
throw new IllegalStateException("@PermitAll contradicts the other security annotations on " + type.getName());
|
||||
}
|
||||
return new SecurityPolicy(!permitAll,
|
||||
roles == null ? AUTHENTICATED.roles : values(roles.value(), "@RolesAllowed", type),
|
||||
roles == null ? AUTHENTICATED.on : roles.on(),
|
||||
scopes == null ? AUTHENTICATED.scopes : values(scopes.value(), "@ScopesAllowed", type));
|
||||
}
|
||||
|
||||
/** False only for {@link PermitAll}. */
|
||||
public boolean required() {
|
||||
return required;
|
||||
}
|
||||
|
||||
public boolean requiresRoles() {
|
||||
return roles.length > 0;
|
||||
}
|
||||
|
||||
public boolean permitsScopes(SecurityIdentity identity) {
|
||||
for (String scope : scopes) if (!identity.hasScope(scope)) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
public boolean permitsRoles(SecurityIdentity identity, Target target) {
|
||||
if (roles.length == 0) return true;
|
||||
for (String role : roles) if (identity.hasRole(role, target)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private static String[] values(String[] values, String annotation, Class<?> type) {
|
||||
if (values.length == 0) throw new IllegalStateException(annotation + " on " + type.getName() + " names nothing");
|
||||
return values;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* A credential as OpenAPI names and defines it, the {@code WWW-Authenticate} challenge an anonymous
|
||||
* API call receives for it, and — for OAuth — the issuer that grants it.
|
||||
*
|
||||
* @param definition the OpenAPI Security Scheme Object, verbatim
|
||||
* @param issuer the authorization server's issuer identifier, {@code null} for anything else
|
||||
*/
|
||||
public record SecurityScheme(String name, Map<String, Object> definition, String challenge, String issuer) {
|
||||
|
||||
public static SecurityScheme bearer(String name, String bearerFormat) {
|
||||
return new SecurityScheme(name, Map.of("type", "http", "scheme", "bearer", "bearerFormat", bearerFormat),
|
||||
"Bearer realm=\"" + name + "\"", null);
|
||||
}
|
||||
|
||||
public static SecurityScheme openIdConnect(String name, String issuer) {
|
||||
return new SecurityScheme(name,
|
||||
Map.of("type", "openIdConnect", "openIdConnectUrl", issuer + (issuer.endsWith("/") ? "" : "/") + ".well-known/openid-configuration"),
|
||||
"Bearer realm=\"" + name + "\"", issuer);
|
||||
}
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
/** A signed-in principal, kept server-side under the id its cookie carries. */
|
||||
public record Session(String id, Principal principal, Instant expiresAt) {}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/** Renews an expired session — with a refresh token, typically. {@code null} ends it instead. */
|
||||
@FunctionalInterface
|
||||
public interface SessionRefresher {
|
||||
|
||||
Session refresh(Session expired);
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/** Where sessions live. {@link InMemorySessionStore} unless one that survives restarts is configured. */
|
||||
public interface SessionStore {
|
||||
|
||||
void save(Session session);
|
||||
|
||||
/** The session, or {@code null}. */
|
||||
Session find(String id);
|
||||
|
||||
void delete(String id);
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/**
|
||||
* The resource a role is checked on: the values {@link RolesAllowed#on()} names, read from path
|
||||
* and query parameters on HTTP and from tool arguments on MCP.
|
||||
*/
|
||||
@FunctionalInterface
|
||||
public interface Target {
|
||||
|
||||
/** A role checked on nothing in particular. */
|
||||
Target NONE = name -> null;
|
||||
|
||||
/** The value called {@code name}, or {@code null} when the call does not carry one. */
|
||||
String get(String name);
|
||||
}
|
||||
+8
@@ -0,0 +1,8 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
/** The application user a verified principal belongs to — typically found, or provisioned, by issuer and subject. */
|
||||
@FunctionalInterface
|
||||
public interface UserResolver<U> {
|
||||
|
||||
U resolve(Principal principal);
|
||||
}
|
||||
+132
@@ -0,0 +1,132 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import dev.relism.flash.ext.openapi.OpenApiExtension;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.testing.FlashTest;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.junit.jupiter.api.extension.RegisterExtension;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
|
||||
class SecurityExtensionTest {
|
||||
|
||||
/** {@code Authorization: Key <name>[ <scope>...]}; {@code Key !} is a presented, invalid credential. */
|
||||
record KeyPrincipal(String name, Set<String> scopes) implements Principal {
|
||||
@Override public boolean hasScope(String scope) { return scopes.contains(scope); }
|
||||
}
|
||||
|
||||
static final AuthenticationMechanism KEY = new AuthenticationMechanism() {
|
||||
@Override
|
||||
public Principal authenticate(Request req) {
|
||||
String header = req.header("Authorization");
|
||||
if (header == null || !header.startsWith("Key ")) return null;
|
||||
String[] parts = header.substring(4).split(" ");
|
||||
if (parts[0].equals("!")) throw new AuthenticationFailedException("Key error=\"invalid_token\"");
|
||||
return new KeyPrincipal(parts[0], Set.of(java.util.Arrays.copyOfRange(parts, 1, parts.length)));
|
||||
}
|
||||
|
||||
@Override
|
||||
public SecurityScheme scheme() {
|
||||
return SecurityScheme.bearer("key", "opaque");
|
||||
}
|
||||
};
|
||||
|
||||
static final SecurityExtension security = new SecurityExtension()
|
||||
.roles((identity, role, on) -> identity.principal().name().equals(role + "@" + on.get("project")))
|
||||
.mechanism(KEY)
|
||||
.loginMethod(new LoginMethod("key", "Key", "/auth/key/login", LoginMethod.Kind.REDIRECT))
|
||||
.refresher(KeyPrincipal.class, expired -> new Session(expired.id(), expired.principal(), Instant.now().plusSeconds(60)));
|
||||
|
||||
@RegisterExtension
|
||||
static final FlashTest app = FlashTest.of(flash -> flash
|
||||
.install(security)
|
||||
.install(new OpenApiExtension("/openapi", "test", "1"))
|
||||
.get("/me", (req, res) -> SecurityIdentity.current().principal().name(), security.enforce(SecurityPolicy.AUTHENTICATED))
|
||||
.post("/login", (req, res) -> {
|
||||
security.signIn(req, res, new KeyPrincipal("carol", Set.of()), Instant.now().minusSeconds(1));
|
||||
return "signed in";
|
||||
})
|
||||
.scan("dev.relism.flash.ext.security.fixtures"));
|
||||
|
||||
@Test
|
||||
void aValidCredentialIdentifiesTheCaller() {
|
||||
app.request().header("Authorization", "Key alice").get("/me").expectStatus(200).expectBody("alice");
|
||||
}
|
||||
|
||||
/** Rejected is never anonymous: a browser presenting a bad credential must not be sent to sign in. */
|
||||
@Test
|
||||
void aRejectedCredentialIs401WithItsOwnChallengeEvenForABrowser() {
|
||||
app.request().header("Authorization", "Key !").header("Accept", "text/html").get("/me")
|
||||
.expectStatus(401)
|
||||
.expectHeader("WWW-Authenticate", "Key error=\"invalid_token\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void anApiCallWithoutCredentialsIsChallengedForEveryMechanism() {
|
||||
app.get("/me").expectStatus(401).expectHeader("WWW-Authenticate", "Bearer realm=\"key\"");
|
||||
}
|
||||
|
||||
@Test
|
||||
void aBrowserWithoutCredentialsGoesStraightToTheOnlyLoginMethod() {
|
||||
app.request().header("Accept", "text/html").get("/me?tab=keys")
|
||||
.expectStatus(302)
|
||||
.expectHeader("Location", "/auth/key/login?redirect=%2Fme%3Ftab%3Dkeys");
|
||||
}
|
||||
|
||||
@Test
|
||||
void permitAllIdentifiesWhoeverAuthenticatesAndToleratesEveryoneElse() {
|
||||
app.request().header("Authorization", "Key bob").get("/open").expectBody("bob");
|
||||
app.request().header("Authorization", "Key !").get("/open").expectStatus(200).expectBody("anonymous");
|
||||
app.get("/open").expectBody("anonymous");
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolesAreCheckedOnTheResourceThePathNames() {
|
||||
app.request().header("Authorization", "Key MANAGER@42").get("/projects/42").expectStatus(200);
|
||||
app.request().header("Authorization", "Key MANAGER@42").get("/projects/7").expectStatus(403);
|
||||
}
|
||||
|
||||
@Test
|
||||
void aMissingScopeIs403WithAnInsufficientScopeChallenge() {
|
||||
app.request().header("Authorization", "Key dave write").post("/write").expectStatus(200);
|
||||
app.request().header("Authorization", "Key dave").post("/write")
|
||||
.expectStatus(403)
|
||||
.expectHeader("WWW-Authenticate", "Bearer error=\"insufficient_scope\", scope=\"write\"");
|
||||
}
|
||||
|
||||
/** The session is signed in already expired, so reaching /me proves the refresher ran. */
|
||||
@Test
|
||||
void aSessionIsRefreshedWhenExpiredAndEndedBySigningOut() {
|
||||
String cookie = app.request().post("/login").expectStatus(200).header("Set-Cookie");
|
||||
String session = cookie.substring(0, cookie.indexOf(';'));
|
||||
|
||||
app.request().header("Cookie", session).get("/me").expectStatus(200).expectBody("carol");
|
||||
app.request().header("Cookie", session).post("/auth/logout").expectStatus(303).expectHeader("Location", "/");
|
||||
app.request().header("Cookie", session).get("/me").expectStatus(401);
|
||||
}
|
||||
|
||||
@Test
|
||||
void loginMethodsAreListed() {
|
||||
app.get("/auth/methods").expectStatus(200).expectBody("[{\"id\":\"key\",\"name\":\"Key\",\"url\":\"/auth/key/login\",\"kind\":\"redirect\"}]");
|
||||
}
|
||||
|
||||
@Test
|
||||
void openApiDocumentsEachSchemeAndWhatEachOperationRequires() {
|
||||
app.get("/openapi.json").expectStatus(200)
|
||||
.expectBodyContains("\"securitySchemes\"")
|
||||
.expectBodyContains("\"bearerFormat\":\"opaque\"")
|
||||
.expectBodyContains("Requires role MANAGER on project")
|
||||
.expectBodyContains("Requires scopes write");
|
||||
}
|
||||
|
||||
@Test
|
||||
void declaringRolesWithoutAResolverFailsTheBoot() {
|
||||
FlashTest broken = FlashTest.of(flash -> flash
|
||||
.install(new SecurityExtension())
|
||||
.scan("dev.relism.flash.ext.security.fixtures"));
|
||||
assertThrows(Exception.class, () -> broken.get("/open"));
|
||||
}
|
||||
}
|
||||
+44
@@ -0,0 +1,44 @@
|
||||
package dev.relism.flash.ext.security;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
class SecurityPolicyTest {
|
||||
|
||||
static class Unannotated {}
|
||||
|
||||
@PermitAll @RolesAllowed("ADMIN")
|
||||
static class Contradictory {}
|
||||
|
||||
@RolesAllowed({})
|
||||
static class NoRoles {}
|
||||
|
||||
@RolesAllowed("ADMIN") @ScopesAllowed("write")
|
||||
static class RolesAndScopes {}
|
||||
|
||||
@Test
|
||||
void anUnannotatedTypeHasNoPolicy() {
|
||||
assertNull(SecurityPolicy.of(Unannotated.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void contradictionsAndEmptyRequirementsFailAtCompileTime() {
|
||||
assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(Contradictory.class));
|
||||
assertThrows(IllegalStateException.class, () -> SecurityPolicy.of(NoRoles.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
void rolesAndScopesBothRequireAuthentication() {
|
||||
SecurityPolicy policy = SecurityPolicy.of(RolesAndScopes.class);
|
||||
assertTrue(policy.required());
|
||||
assertTrue(policy.requiresRoles());
|
||||
assertFalse(SecurityPolicy.of(OpenAccess.class).required());
|
||||
}
|
||||
|
||||
@PermitAll
|
||||
static class OpenAccess {}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.security.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.security.PermitAll;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.GET;
|
||||
|
||||
@GET("/open")
|
||||
@PermitAll
|
||||
public final class OpenHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) {
|
||||
SecurityIdentity identity = SecurityIdentity.current();
|
||||
return identity == null ? "anonymous" : identity.principal().name();
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
package dev.relism.flash.ext.security.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||
import dev.relism.flash.ext.security.RolesAllowed;
|
||||
import dev.relism.flash.ext.security.SecurityIdentity;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.GET;
|
||||
|
||||
@GET("/projects/{project}")
|
||||
@ApiOperation(summary = "fixture")
|
||||
@RolesAllowed(value = "MANAGER", on = "project")
|
||||
public final class ProjectHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) {
|
||||
return SecurityIdentity.current().principal().name();
|
||||
}
|
||||
}
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
package dev.relism.flash.ext.security.fixtures;
|
||||
|
||||
import dev.relism.flash.ext.openapi.ApiOperation;
|
||||
import dev.relism.flash.ext.security.ScopesAllowed;
|
||||
import dev.relism.flash.models.Request;
|
||||
import dev.relism.flash.models.RequestHandler;
|
||||
import dev.relism.flash.models.Response;
|
||||
import dev.relism.flash.routing.POST;
|
||||
|
||||
@POST("/write")
|
||||
@ApiOperation(summary = "fixture")
|
||||
@ScopesAllowed("write")
|
||||
public final class WriteHandler extends RequestHandler {
|
||||
@Override
|
||||
public Object handle(Request req, Response res) {
|
||||
return "written";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user