Gitlab Community Edition Instance

Commit 8e3724a1 authored by mhellka's avatar mhellka
Browse files

Extend JWT Plugin to support JKWS and dynamic validators and extractors.

parent c9ee0cc3
Pipeline #289965 passed with stage
in 2 minutes and 38 seconds
......@@ -31,27 +31,26 @@ public interface Subject {
}
/**
* Return true if this subject was remembered by an {@link Authenticator},
* but not logged in using any form of credentials.
* Return true if this subject was remembered by an {@link Authenticator}, but
* not logged in using any form of credentials.
*
* This may be used as an indicator of lowered security to an application.
* For example, a web service may allow users to come back within a certain
* time frame and be logged in automatically (e.g. via cookies), but require
* an explicit login for highly sensitive operations such as changing the
* password.
* This may be used as an indicator of lowered security to an application. For
* example, a web service may allow users to come back within a certain time
* frame and be logged in automatically (e.g. via cookies), but require an
* explicit login for highly sensitive operations such as changing the password.
*/
boolean isRemembered();
/**
* Return the token associated with this subject, if remembered, or create a
* new token. Requires a SessionStore realm.
* Return the token associated with this subject, if remembered, or create a new
* token. Requires a SessionStore realm.
*/
String remember();
/**
* Try to authenticate this subject with the given credentials. Return true
* on success, false otherwise. Calling this on an already authenticated
* subject triggers a {@link #logout()}.
* Try to authenticate this subject with the given credentials. Return true on
* success, false otherwise. Calling this on an already authenticated subject
* triggers a {@link #logout()}.
*/
boolean tryLogin(Credentials request);
......@@ -61,8 +60,8 @@ public interface Subject {
}
/**
* Turn the subject into an anonymous subject and tell the
* {@link Authenticator} to forget about it.
* Turn the subject into an anonymous subject and tell the {@link Authenticator}
* to forget about it.
*/
void logout();
......@@ -72,15 +71,16 @@ public interface Subject {
Principal getPrincipal();
/**
* Return true if the subject is member of the given group (assuming the
* same domain than the principal).
* Return true if the subject is member of the given group (assuming the same
* domain than the principal).
*/
default boolean isMemberOf(String group) {
return hasPrincipal() && isMemberOf(group, getPrincipal().getDomain());
return hasPrincipal() && isMemberOf(group, null);
}
/**
* Return true if the subject is member of the given group.
* Return true if the subject is member of the given group. If the group domain
* is null, the same domain as the principal is assumed.
*/
boolean isMemberOf(String group, String groupDomain);
......
......@@ -21,16 +21,16 @@ public interface Authorizer extends Realm {
* restrict the session to certain permission scopes (e.g. read-only access
* tokens).
*
* Return {@link CheckResult#PERMIT} if the principal is allowed to perform this
* action, {@link CheckResult#DENY} if the principal is blocked from performing
* this action, or {@link CheckResult#SKIP} if the principal is not known or is
* Return {@link CheckResult#YES} if the principal is allowed to perform this
* action, {@link CheckResult#NO} if the principal is blocked from performing
* this action, or {@link CheckResult#UNKNOWN} if the principal is not known or is
* not explicitly allowed or denied this action.
*
* The end result depends on the return values of all installed
* {@link Authorizer}s: The action is granted only if at least one Authorizer
* returns {@link CheckResult#PERMIT} and not a single {@link CheckResult#DENY}
* is returned. In other words, {@link CheckResult#DENY} will overrule
* {@link CheckResult#PERMIT} and should only be used by {@link Authorizer}s
* returns {@link CheckResult#YES} and not a single {@link CheckResult#NO}
* is returned. In other words, {@link CheckResult#NO} will overrule
* {@link CheckResult#YES} and should only be used by {@link Authorizer}s
* that wish to restrict other more permissive {@link Authorizer}s.
*
* @param p
......@@ -41,8 +41,4 @@ public interface Authorizer extends Realm {
* @return {@link CheckResult}
*/
CheckResult isPermitted(Session session, Permission p);
enum CheckResult {
PERMIT, DENY, SKIP;
}
}
package de.gwdg.cdstar.auth.realm;
import java.util.function.Function;
public enum CheckResult {
YES, NO, UNKNOWN;
public static CheckResult voteReduce(CheckResult cur, CheckResult next) {
if (cur == null || cur == CheckResult.UNKNOWN)
return next;
if (cur == CheckResult.NO || next == CheckResult.NO)
return CheckResult.NO;
return CheckResult.YES;
}
public static <T> boolean vote(Iterable<T> voters, Function<T, CheckResult> castVote) {
CheckResult result = CheckResult.UNKNOWN;
for(T voter: voters) {
result = voteReduce(result, castVote.apply(voter));
if(result == NO)
return false;
}
return result == YES;
}
}
\ No newline at end of file
......@@ -12,29 +12,32 @@ public interface GroupResolver extends Realm {
/**
* Check if a given {@link Principal} is member of a specific group.
*
* This method takes a {@link Session} instead of a {@link Principal}
* because the same principal may have different group memberships based on
* the authentication method. For example, a token based authentication may
* restrict the groups a principal is allowed to identify with (limited
* access token).
* This method takes a {@link Session} instead of a {@link Principal} because
* the same principal may have different group memberships based on the
* authentication method. For example, a token based authentication may restrict
* the groups a principal is allowed to identify with (limited access token).
*
* @param session
* The session (or principal) under test.
* @param groupName
* Name of the group (without leading '@')
* @param groupDomain
* Domain of the group.
* The {@link GroupResolver} must return {@link CheckResult#YES} if the
* principal is member of the given group, {@link CheckResult#NO} if it is not
* member of the given group or {@link CheckResult#UNKNOWN} if the membership
* status is not known. A principal is considered member of a group if at least
* one {@link GroupResolver} votes {@link CheckResult#YES} and no
* {@link GroupResolver} votes {@link CheckResult#NO};
*
* @return True if the principal is member of the group, false otherwise.
* @param session The session (or principal) under test.
* @param groupName Name of the group (without leading '@')
* @param groupDomain Domain of the group.
*
* @return Group membership status.
*/
boolean isMemberOf(Session session, String groupName, String groupDomain);
CheckResult isMemberOf(Session session, String groupName, String groupDomain);
/**
* Same as {@link #isMemberOf(Session, String, String)} but passes the
* principals domain (as returned by {@link Principal#getDomain()} as the
* group domain.
* principals domain (as returned by {@link Principal#getDomain()} as the group
* domain.
*/
default boolean isMemberOf(Session session, String groupName) {
default CheckResult isMemberOf(Session session, String groupName) {
return isMemberOf(session, groupName, session.getPrincipal().getDomain());
}
......
......@@ -56,15 +56,34 @@ public class SubjectImpl implements Subject {
if (session == null)
return false;
if(groupDomain == null)
groupDomain = getPrincipal().getDomain();
// Check original permission
GroupResolver grantingGroupResolver = null;
for (final GroupResolver gs : config.getRealmsByClass(GroupResolver.class)) {
if (gs.isMemberOf(session, groupName, groupDomain)) {
log.debug("Group check confirmed: [{}@{}] for [{}] by [{}]", groupName, groupDomain, this, gs);
return true;
switch (gs.isMemberOf(session, groupName, groupDomain)) {
case NO:
log.debug("Group check denied: [{}@{}] for [{}] by [{}]", groupName, groupDomain, this, gs);
return false;
case YES:
if(grantingGroupResolver == null)
grantingGroupResolver = gs;
continue;
case UNKNOWN:
default:
continue;
}
}
if(grantingGroupResolver != null) {
log.debug("Group check confirmed: [{}@{}] for [{}] by [{}]", groupName, groupDomain, this, grantingGroupResolver);
return true;
}
log.debug("Group check denied: [{}@{}] for [{}]", groupName, groupDomain, this);
return false;
}
@Override
......@@ -91,14 +110,14 @@ public class SubjectImpl implements Subject {
for (final Permission pr : derieved) {
for (final Authorizer auth : authorizers) {
switch (auth.isPermitted(session, p)) {
case DENY:
case NO:
log.info("Permission [{}] denied for [{}] by [{}]", pr, this, auth);
return null;
case PERMIT:
case YES:
log.debug("Permission [{}] granted for [{}] by [{}]", pr, this, auth);
permittetBy = auth;
break;
case SKIP:
case UNKNOWN:
default:
break;
}
......
......@@ -74,27 +74,27 @@ public class DomainAuthorizer implements Authorizer, GroupResolver {
}
@Override
public boolean isMemberOf(Session session, String groupName, String groupDomain) {
public CheckResult isMemberOf(Session session, String groupName, String groupDomain) {
final String domain = session.getPrincipal().getDomain();
final Set<QName> groups = domainGroups.get(domain);
if (groups == null || groups.isEmpty())
return false;
return groups.contains(new QName(groupName, groupDomain));
if(groups != null && groups.contains(new QName(groupName, groupDomain)))
return CheckResult.YES;
return CheckResult.UNKNOWN;
}
@Override
public CheckResult isPermitted(Session session, Permission p) {
if (!(p instanceof StringPermission))
return CheckResult.SKIP;
return CheckResult.UNKNOWN;
final String domain = session.getPrincipal().getDomain();
final Set<StringPermission> grants = domainGrants.get(domain);
if (grants == null || grants.isEmpty())
return CheckResult.SKIP;
return CheckResult.UNKNOWN;
for (final StringPermission grant : grants)
if (grant.implies(p))
return CheckResult.PERMIT;
return CheckResult.SKIP;
return CheckResult.YES;
return CheckResult.UNKNOWN;
}
}
......@@ -17,6 +17,7 @@ import de.gwdg.cdstar.auth.StringPermission;
import de.gwdg.cdstar.auth.UsernamePasswordCredentials;
import de.gwdg.cdstar.auth.realm.Authenticator;
import de.gwdg.cdstar.auth.realm.Authorizer;
import de.gwdg.cdstar.auth.realm.CheckResult;
import de.gwdg.cdstar.auth.realm.GroupResolver;
/**
......@@ -121,38 +122,37 @@ public class SimpleAuthorizer implements Authorizer, Authenticator, GroupResolve
public CheckResult isPermitted(Session session, Permission p) {
final Account acc = resolveAccount(session);
if (acc == null)
return CheckResult.SKIP;
return CheckResult.UNKNOWN;
// TODO: Cache these.
// Check direct permissions
for (final StringPermission accp : acc.permissions)
if (accp.implies(p))
return CheckResult.PERMIT;
return CheckResult.YES;
// Check role->permissions
for (final String role : acc.roles)
for (final StringPermission accp : roles.getOrDefault(role, Collections.emptySet()))
if (accp.implies(p))
return CheckResult.PERMIT;
return CheckResult.YES;
// Check group->role->permissions
for (final QName group : acc.groups)
for (final String role : groups.getOrDefault(group, Collections.emptySet()))
for (final StringPermission accp : roles.getOrDefault(role, Collections.emptySet()))
if (accp.implies(p))
return CheckResult.PERMIT;
return CheckResult.YES;
return CheckResult.SKIP;
return CheckResult.UNKNOWN;
}
@Override
public boolean isMemberOf(Session session, String groupName, String groupDomain) {
public CheckResult isMemberOf(Session session, String groupName, String groupDomain) {
final Account acc = resolveAccount(session);
if (acc == null)
return false;
return acc.groups.contains(new QName(groupName, groupDomain));
if(acc != null && acc.groups.contains(new QName(groupName, groupDomain)))
return CheckResult.YES;
return CheckResult.UNKNOWN;
}
@Override
......
package de.gwdg.cdstar.auth;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Rule;
import org.junit.Test;
import de.gwdg.cdstar.auth.realm.Authorizer.CheckResult;
import de.gwdg.cdstar.auth.realm.CheckResult;
import de.gwdg.cdstar.auth.realm.DomainAuthorizer;
import de.gwdg.cdstar.utils.test.TestLogger;
......@@ -22,16 +20,16 @@ public class DomainAuthorizerTest {
a.addDomainPermission("GWDG", StringPermission.parse("vault:GWDG:*"));
a.addDomainGroup("GWDG", "user", "other@OTHER");
assertTrue(a.isMemberOf(new MockSession("foo", "GWDG"), "user", "GWDG"));
assertTrue(a.isMemberOf(new MockSession("foo", "GWDG"), "other", "OTHER"));
assertEquals(CheckResult.PERMIT, a.isPermitted(new MockSession("foo", "GWDG"), StringPermission.parse("vault:GWDG:anything")));
assertEquals(CheckResult.YES, a.isMemberOf(new MockSession("foo", "GWDG"), "user", "GWDG"));
assertEquals(CheckResult.YES, a.isMemberOf(new MockSession("foo", "GWDG"), "other", "OTHER"));
assertEquals(CheckResult.YES, a.isPermitted(new MockSession("foo", "GWDG"), StringPermission.parse("vault:GWDG:anything")));
assertFalse(a.isMemberOf(new MockSession("foo", "GWDG"), "other", "GWDG"));
assertFalse(a.isMemberOf(new MockSession("foo", "GWDG"), "user", "OTHER"));
assertEquals(CheckResult.SKIP, a.isPermitted(new MockSession("foo", "GWDG"), StringPermission.parse("vault:other:anything")));
assertEquals(CheckResult.UNKNOWN, a.isMemberOf(new MockSession("foo", "GWDG"), "other", "GWDG"));
assertEquals(CheckResult.UNKNOWN, a.isMemberOf(new MockSession("foo", "GWDG"), "user", "OTHER"));
assertEquals(CheckResult.UNKNOWN, a.isPermitted(new MockSession("foo", "GWDG"), StringPermission.parse("vault:other:anything")));
assertFalse(a.isMemberOf(new MockSession("foo", "GWDG2"), "user", "GWDG"));
assertEquals(CheckResult.SKIP, a.isPermitted(new MockSession("foo", "GWDG2"), StringPermission.parse("vault:GWDG:anything")));
assertEquals(CheckResult.UNKNOWN, a.isMemberOf(new MockSession("foo", "GWDG2"), "user", "GWDG"));
assertEquals(CheckResult.UNKNOWN, a.isPermitted(new MockSession("foo", "GWDG2"), StringPermission.parse("vault:GWDG:anything")));
}
......
......@@ -8,7 +8,7 @@ import static org.junit.Assert.assertTrue;
import org.junit.Before;
import org.junit.Test;
import de.gwdg.cdstar.auth.realm.Authorizer.CheckResult;
import de.gwdg.cdstar.auth.realm.CheckResult;
import de.gwdg.cdstar.auth.simple.Account;
import de.gwdg.cdstar.auth.simple.SimpleAuthorizer;
......@@ -86,9 +86,9 @@ public class StaticAuthorizerTest {
assertTrue(check(session, "user:stuff"));
assertFalse(check(session, "admin:stuff"));
assertTrue(auth.isMemberOf(session, "user"));
assertTrue(auth.isMemberOf(session, "user", acc.getDomain()));
assertFalse(auth.isMemberOf(session, "admin"));
assertEquals(CheckResult.YES, auth.isMemberOf(session, "user"));
assertEquals(CheckResult.YES, auth.isMemberOf(session, "user", acc.getDomain()));
assertEquals(CheckResult.UNKNOWN, auth.isMemberOf(session, "admin"));
}
@Test
......@@ -110,11 +110,11 @@ public class StaticAuthorizerTest {
assertTrue(check(mockSession, "do:stuff"));
assertFalse(check(mockSession, "anon:stuff"));
assertTrue(auth.isMemberOf(mockSession, "ldapGroup", "LDAP"));
assertEquals(CheckResult.YES, auth.isMemberOf(mockSession, "ldapGroup", "LDAP"));
}
private boolean check(Session session, String permission) {
return CheckResult.PERMIT == auth.isPermitted(session, StringPermission.parse(permission));
return CheckResult.YES == auth.isPermitted(session, StringPermission.parse(permission));
}
}
package de.gwdg.cdstar.auth;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotNull;
import static org.junit.Assert.assertNull;
import static org.junit.Assert.assertTrue;
import org.junit.Rule;
import org.junit.Test;
import de.gwdg.cdstar.auth.realm.Authorizer.CheckResult;
import de.gwdg.cdstar.auth.realm.CheckResult;
import de.gwdg.cdstar.auth.realm.StaticRealm;
import de.gwdg.cdstar.config.MapConfig;
import de.gwdg.cdstar.runtime.Config;
......@@ -47,12 +45,12 @@ public class StaticRealmTest {
assertEquals("static", session.getPrincipal().getDomain());
assertEquals("test", session.getPrincipal().getId());
assertTrue(realm.isMemberOf(session, "testGroup"));
assertFalse(realm.isMemberOf(session, "fakeGroup"));
assertEquals(CheckResult.YES, realm.isMemberOf(session, "testGroup"));
assertEquals(CheckResult.UNKNOWN, realm.isMemberOf(session, "fakeGroup"));
assertEquals(CheckResult.PERMIT, realm.isPermitted(session, StringPermission.parse("do:stuff")));
assertEquals(CheckResult.PERMIT, realm.isPermitted(session, StringPermission.parse("role:stuff")));
assertEquals(CheckResult.PERMIT, realm.isPermitted(session, StringPermission.parse("group:stuff")));
assertEquals(CheckResult.YES, realm.isPermitted(session, StringPermission.parse("do:stuff")));
assertEquals(CheckResult.YES, realm.isPermitted(session, StringPermission.parse("role:stuff")));
assertEquals(CheckResult.YES, realm.isPermitted(session, StringPermission.parse("group:stuff")));
}
@Test
......@@ -71,7 +69,7 @@ public class StaticRealmTest {
final StaticRealm realm = new StaticRealm(cfg);
assertEquals("static", realm.getDomain());
assertEquals("myDomain", realm.account("test", "myDomain").getDomain());
assertEquals(CheckResult.PERMIT, realm.isPermitted(new MockSession("test", "myDomain"), StringPermission.parse("do:stuff")));
assertEquals(CheckResult.YES, realm.isPermitted(new MockSession("test", "myDomain"), StringPermission.parse("do:stuff")));
}
@Test
......@@ -85,7 +83,7 @@ public class StaticRealmTest {
// Still authorizes user, even if it comes from a different
// authenticator
assertEquals(CheckResult.PERMIT, realm.isPermitted(new MockSession("test", "static"), StringPermission.parse("do:stuff")));
assertEquals(CheckResult.YES, realm.isPermitted(new MockSession("test", "static"), StringPermission.parse("do:stuff")));
}
}
......@@ -6,6 +6,6 @@ import java.util.function.Supplier;
* Like {@link Supplier}, but allowed to throw checked exceptions.
*/
@FunctionalInterface
public interface FailableSupplier<T, E extends Exception> {
public interface FailableSupplier<T, E extends Throwable> {
T supply() throws E;
}
\ No newline at end of file
package de.gwdg.cdstar;
import java.util.function.Function;
public class Result<T, E extends Throwable> {
private T value;
private E error;
Result(T value, E error) {
this.value = value;
this.error = error;
}
public T unwrap() throws E {
if(error != null)
throw error;
return value;
}
public T get(T fallback) {
return error == null ? value : fallback;
}
public E error() {
return error;
}
public static <T, E extends Throwable> Result<T, E> of(T value) {
return new Result<>(value, null);
}
public static <T, E extends Throwable> Result<T, E> error(E error) {
return new Result<>(null, error);
}
public <V> Result<V, E> flatMap(Function<T, Result<V, E>> f) {
if (this.error != null)
return Result.error(error);
return f.apply(value);
}
public <V> Result<V, E> map(Function<T, V> f) {
return flatMap(f.andThen(Result::of));
}
}
\ No newline at end of file
......@@ -4,8 +4,7 @@ This plugin adds support for JWT token based authentication and authorization.
== Configuration
The `JWTRealm` class can be configured as a realm or regular plugin
and supports multiple issuers per instance.
The `JWTRealm` class can be configured as a realm or regular plugin and allows users to authenticate via signed JWTs.
.example.yaml
[source,yaml]
......@@ -14,65 +13,74 @@ realm:
jwt:
class: JWTRealm
default:
hmac: c3VwZXJzZWNyZXQ= # base64("supersecret")
hmac: c3VwZXJzZWNyZXQ= # base64("supersecret")
my_issuer:
hmac: c3VwZXJzZWNyZXQ=
rsa: /path/to/rsa_pub.pem
ecdsa: /path/to/ecdsa_pub.der
alg: HS256,RS256,ES256
iss: My Issuer String
domain: my_realm
permit: "vault:myVault:*, vault:myOtherVault:*"
trusted: true
iss: https://auth.example.com/my-realm/
jwks: https://auth.example.com/my-realm/jwks.json
domain: my_realm
----
JWT tokens are matched against configured issuers based in their `iss` header claim.
Tokens without an `iss` header claim will be matched with the `default` issuer, if present.
This plugin supports multiple JWT issuers with different settings at the same time. Tokens are matched against configured issuers based in their `iss` claim. Tokens without an `iss` claim or with no matching issuer configuration will be matched against the `default` issuer, if defined.
Each issuer MUST define one of `hmac`, `rsa` or `ecdsa` to be able to verify signed tokens.
Unsigned tokens are not supported and will be rejected.
Each issuer MUST define at least one of `hmac`, `rsa`, `ecdsa` or `jwks` to be able to verify signed tokens. Unsigned tokens are not supported and will be rejected.
[cols="0v,0v,1"]
|====================
| Pram | Description |
| class | Plugin class name. Always `JwtRealm` |
| <issuer>.iss | Expected value of the `iss` header claim for tokens from this issuer. (default: +<issuer>+). |
| <issuer>.hmac | Base64 encoded secret. Required to verify HMAC based signatures. |
| <issuer>.rsa | RSA public key (X.509). Required to verify RSA based signatures. Keys are loaded from (+*.pem+ or +*.der+) files, or directly from a base64 encoded string. |
| <issuer>.ecdsa | ECDSA public key (X.509). Required to verify ECDSA based signatures. Keys are loaded from (+*.pem+ or +*.der+) files, or directly from a base64 encoded string. |
| <issuer>.alg | List of allowed JWT signing algorithms. (default: All supported algorithms) |
| <issuer>.iss | Expected value of the `iss` header claim for tokens from this issuer. (default: +<issuer>+). |
| <issuer>.jwks | Path or URL pointing to a JWKS (Java WebToken Key Set) file to load signing keys from. |
| <issuer>.leeway | Number of seconds to add/remove to `exp` or `nbf` claims before a token is checked. This helps prevent errors for short-lived tokens if the server clocks are not perfectly synchronized. (default: +0+). |
| <issuer>.domain | The realm domain of the resulting principal. (default: +<issuer>+). |
| <issuer>.trusted | If `true`, the issuer can dynamically grant additional permissions via private claims (see below). (default: `false`) |
| <issuer>.permit | A list of StringPermissions given to all tokens created by this issuer. |
| <issuer>.trusted | (deprecated) If `true`, the issuer can dynamically grant additional permissions via private claims (see below). (default: `false`) |
| <issuer>.permit | A list of static StringPermissions given to all tokens created by this issuer. |
| <issuer>.groups | A list of static groups all token users are considered to be a member of. |
| <issuer>.subject | SpEL expression to derive a subject name from a token. Must evaluate to a string. (default: +getString('sub')+) |
| <issuer>.verify.<name> | SpEL expression (see below) to check token validity. All expressions must evaluate to `true`, or the token will be rejected. The rule name is just informal. |
| <issuer>.groups.<name> | SpEL expression (see below) to derive group memberships from a token. Each expression must evaluate to a string, a list of strings, or null. Null values or empty lists are ignored and will not add any groups. The expression name is just informal. |