KEYCLOAK-14556 Authentication session map store
This commit is contained in:
parent
7f916ad20c
commit
f6be378eca
23 changed files with 1275 additions and 85 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
@ -121,7 +121,7 @@ jobs:
|
|||
run: |
|
||||
declare -A PARAMS TESTGROUP
|
||||
PARAMS["quarkus"]="-Pauth-server-quarkus"
|
||||
PARAMS["undertow-map"]="-Pauth-server-undertow -Dkeycloak.client.provider=map -Dkeycloak.group.provider=map -Dkeycloak.role.provider=map"
|
||||
PARAMS["undertow-map"]="-Pauth-server-undertow -Dkeycloak.client.provider=map -Dkeycloak.group.provider=map -Dkeycloak.role.provider=map -Dkeycloak.authSession.provider=map"
|
||||
PARAMS["wildfly"]="-Pauth-server-wildfly"
|
||||
TESTGROUP["group1"]="-Dtest=!**.crossdc.**,!**.cluster.**,%regex[org.keycloak.testsuite.(a[abc]|ad[a-l]|[^a-q]).*]" # Tests alphabetically before admin tests and those after "r"
|
||||
TESTGROUP["group2"]="-Dtest=!**.crossdc.**,!**.cluster.**,%regex[org.keycloak.testsuite.(ad[^a-l]|a[^a-d]|b).*]" # Admin tests and those starting with "b"
|
||||
|
|
|
@ -69,12 +69,12 @@ public class InfinispanAuthenticationSessionProvider implements AuthenticationSe
|
|||
@Override
|
||||
public RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm) {
|
||||
String id = keyGenerator.generateKeyString(session, cache);
|
||||
return createRootAuthenticationSession(id, realm);
|
||||
return createRootAuthenticationSession(realm, id);
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public RootAuthenticationSessionModel createRootAuthenticationSession(String id, RealmModel realm) {
|
||||
public RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm, String id) {
|
||||
RootAuthenticationSessionEntity entity = new RootAuthenticationSessionEntity();
|
||||
entity.setId(id);
|
||||
entity.setRealmId(realm.getId());
|
||||
|
|
|
@ -0,0 +1,104 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.models.map.common.AbstractEntity;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public abstract class AbstractRootAuthenticationSessionEntity<K> implements AbstractEntity<K> {
|
||||
|
||||
private K id;
|
||||
private String realmId;
|
||||
|
||||
/**
|
||||
* Flag signalizing that any of the setters has been meaningfully used.
|
||||
*/
|
||||
protected boolean updated;
|
||||
private int timestamp;
|
||||
private Map<String, MapAuthenticationSessionEntity> authenticationSessions = new ConcurrentHashMap<>();
|
||||
|
||||
protected AbstractRootAuthenticationSessionEntity() {
|
||||
this.id = null;
|
||||
this.realmId = null;
|
||||
}
|
||||
|
||||
public AbstractRootAuthenticationSessionEntity(K id, String realmId) {
|
||||
Objects.requireNonNull(id, "id");
|
||||
Objects.requireNonNull(realmId, "realmId");
|
||||
|
||||
this.id = id;
|
||||
this.realmId = realmId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public K getId() {
|
||||
return this.id;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean isUpdated() {
|
||||
return this.updated;
|
||||
}
|
||||
|
||||
public String getRealmId() {
|
||||
return realmId;
|
||||
}
|
||||
|
||||
public void setRealmId(String realmId) {
|
||||
this.updated |= !Objects.equals(this.realmId, realmId);
|
||||
this.realmId = realmId;
|
||||
}
|
||||
|
||||
public int getTimestamp() {
|
||||
return timestamp;
|
||||
}
|
||||
|
||||
public void setTimestamp(int timestamp) {
|
||||
this.updated |= !Objects.equals(this.timestamp, timestamp);
|
||||
this.timestamp = timestamp;
|
||||
}
|
||||
|
||||
public Map<String, MapAuthenticationSessionEntity> getAuthenticationSessions() {
|
||||
return authenticationSessions;
|
||||
}
|
||||
|
||||
public void setAuthenticationSessions(Map<String, MapAuthenticationSessionEntity> authenticationSessions) {
|
||||
this.updated |= !Objects.equals(this.authenticationSessions, authenticationSessions);
|
||||
this.authenticationSessions = authenticationSessions;
|
||||
}
|
||||
|
||||
public MapAuthenticationSessionEntity removeAuthenticationSession(String tabId) {
|
||||
MapAuthenticationSessionEntity entity = this.authenticationSessions.remove(tabId);
|
||||
this.updated |= entity != null;
|
||||
return entity;
|
||||
}
|
||||
|
||||
public void addAuthenticationSession(String tabId, MapAuthenticationSessionEntity entity) {
|
||||
this.updated |= !Objects.equals(this.authenticationSessions.put(tabId, entity), entity);
|
||||
}
|
||||
|
||||
public void clearAuthenticationSessions() {
|
||||
this.updated |= !this.authenticationSessions.isEmpty();
|
||||
this.authenticationSessions.clear();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.map.common.AbstractEntity;
|
||||
import org.keycloak.sessions.RootAuthenticationSessionModel;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public abstract class AbstractRootAuthenticationSessionModel<E extends AbstractEntity> implements RootAuthenticationSessionModel {
|
||||
|
||||
protected final KeycloakSession session;
|
||||
protected final RealmModel realm;
|
||||
protected final E entity;
|
||||
|
||||
public AbstractRootAuthenticationSessionModel(KeycloakSession session, RealmModel realm, E entity) {
|
||||
Objects.requireNonNull(entity, "entity");
|
||||
Objects.requireNonNull(realm, "realm");
|
||||
|
||||
this.session = session;
|
||||
this.realm = realm;
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object o) {
|
||||
if (this == o) return true;
|
||||
if (!(o instanceof RootAuthenticationSessionModel)) return false;
|
||||
|
||||
RootAuthenticationSessionModel that = (RootAuthenticationSessionModel) o;
|
||||
return Objects.equals(that.getId(), getId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return getId().hashCode();
|
||||
}
|
||||
}
|
|
@ -0,0 +1,258 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserModel;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.sessions.RootAuthenticationSessionModel;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapAuthenticationSessionAdapter implements AuthenticationSessionModel {
|
||||
|
||||
private final KeycloakSession session;
|
||||
private final MapRootAuthenticationSessionAdapter parent;
|
||||
private final String tabId;
|
||||
private MapAuthenticationSessionEntity entity;
|
||||
|
||||
public MapAuthenticationSessionAdapter(KeycloakSession session, MapRootAuthenticationSessionAdapter parent,
|
||||
String tabId, MapAuthenticationSessionEntity entity) {
|
||||
this.session = session;
|
||||
this.parent = parent;
|
||||
this.tabId = tabId;
|
||||
this.entity = entity;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getTabId() {
|
||||
return tabId;
|
||||
}
|
||||
|
||||
@Override
|
||||
public RootAuthenticationSessionModel getParentSession() {
|
||||
return parent;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, ExecutionStatus> getExecutionStatus() {
|
||||
return entity.getExecutionStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setExecutionStatus(String authenticator, ExecutionStatus status) {
|
||||
Objects.requireNonNull(authenticator, "The provided authenticator can't be null!");
|
||||
Objects.requireNonNull(status, "The provided execution status can't be null!");
|
||||
parent.setUpdated(!Objects.equals(entity.getExecutionStatus().put(authenticator, status), status));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearExecutionStatus() {
|
||||
parent.setUpdated(!entity.getExecutionStatus().isEmpty());
|
||||
entity.getExecutionStatus().clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserModel getAuthenticatedUser() {
|
||||
return entity.getAuthUserId() == null ? null : session.users().getUserById(entity.getAuthUserId(), getRealm());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthenticatedUser(UserModel user) {
|
||||
String userId = (user == null) ? null : user.getId();
|
||||
parent.setUpdated(!Objects.equals(userId, entity.getAuthUserId()));
|
||||
entity.setAuthUserId(userId);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getRequiredActions() {
|
||||
return new HashSet<>(entity.getRequiredActions());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addRequiredAction(String action) {
|
||||
Objects.requireNonNull(action, "The provided action can't be null!");
|
||||
parent.setUpdated(entity.getRequiredActions().add(action));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeRequiredAction(String action) {
|
||||
Objects.requireNonNull(action, "The provided action can't be null!");
|
||||
parent.setUpdated(entity.getRequiredActions().remove(action));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void addRequiredAction(UserModel.RequiredAction action) {
|
||||
Objects.requireNonNull(action, "The provided action can't be null!");
|
||||
addRequiredAction(action.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeRequiredAction(UserModel.RequiredAction action) {
|
||||
Objects.requireNonNull(action, "The provided action can't be null!");
|
||||
removeRequiredAction(action.name());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setUserSessionNote(String name, String value) {
|
||||
if (name != null) {
|
||||
if (value == null) {
|
||||
parent.setUpdated(entity.getUserSessionNotes().remove(name) != null);
|
||||
} else {
|
||||
parent.setUpdated(!Objects.equals(entity.getUserSessionNotes().put(name, value), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getUserSessionNotes() {
|
||||
return new ConcurrentHashMap<>(entity.getUserSessionNotes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearUserSessionNotes() {
|
||||
parent.setUpdated(!entity.getUserSessionNotes().isEmpty());
|
||||
entity.getUserSessionNotes().clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAuthNote(String name) {
|
||||
return (name != null) ? entity.getAuthNotes().get(name) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAuthNote(String name, String value) {
|
||||
if (name != null) {
|
||||
if (value == null) {
|
||||
parent.setUpdated(entity.getAuthNotes().remove(name) != null);
|
||||
} else {
|
||||
parent.setUpdated(!Objects.equals(entity.getAuthNotes().put(name, value), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAuthNote(String name) {
|
||||
if (name != null) {
|
||||
parent.setUpdated(entity.getAuthNotes().remove(name) != null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearAuthNotes() {
|
||||
parent.setUpdated(!entity.getAuthNotes().isEmpty());
|
||||
entity.getAuthNotes().clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getClientNote(String name) {
|
||||
return (name != null) ? entity.getClientNotes().get(name) : null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClientNote(String name, String value) {
|
||||
if (name != null) {
|
||||
if (value == null) {
|
||||
parent.setUpdated(entity.getClientNotes().remove(name) != null);
|
||||
} else {
|
||||
parent.setUpdated(!Objects.equals(entity.getClientNotes().put(name, value), value));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeClientNote(String name) {
|
||||
if (name != null) {
|
||||
parent.setUpdated(entity.getClientNotes().remove(name) != null);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, String> getClientNotes() {
|
||||
return new ConcurrentHashMap<>(entity.getClientNotes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void clearClientNotes() {
|
||||
parent.setUpdated(!entity.getClientNotes().isEmpty());
|
||||
entity.getClientNotes().clear();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getClientScopes() {
|
||||
return new HashSet<>(entity.getClientScopes());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setClientScopes(Set<String> clientScopes) {
|
||||
Objects.requireNonNull(clientScopes, "The provided client scopes set can't be null!");
|
||||
parent.setUpdated(!Objects.equals(entity.getClientScopes(), clientScopes));
|
||||
entity.setClientScopes(new HashSet<>(clientScopes));
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getRedirectUri() {
|
||||
return entity.getRedirectUri();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setRedirectUri(String uri) {
|
||||
parent.setUpdated(!Objects.equals(entity.getRedirectUri(), uri));
|
||||
entity.setRedirectUri(uri);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealmModel getRealm() {
|
||||
return parent.getRealm();
|
||||
}
|
||||
|
||||
@Override
|
||||
public ClientModel getClient() {
|
||||
return parent.getRealm().getClientById(entity.getClientUUID());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getAction() {
|
||||
return entity.getAction();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setAction(String action) {
|
||||
parent.setUpdated(!Objects.equals(entity.getAction(), action));
|
||||
entity.setAction(action);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getProtocol() {
|
||||
return entity.getProtocol();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setProtocol(String method) {
|
||||
parent.setUpdated(!Objects.equals(entity.getProtocol(), method));
|
||||
entity.setProtocol(method);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,72 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.cluster.ClusterEvent;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapAuthenticationSessionAuthNoteUpdateEvent implements ClusterEvent {
|
||||
|
||||
private String authSessionId;
|
||||
private String tabId;
|
||||
private String clientUUID;
|
||||
|
||||
private Map<String, String> authNotesFragment;
|
||||
|
||||
/**
|
||||
* Creates an instance of the event.
|
||||
* @param authSessionId
|
||||
* @param authNotesFragment
|
||||
* @return Event. Note that {@code authNotesFragment} property is not thread safe which is fine for now.
|
||||
*/
|
||||
public static MapAuthenticationSessionAuthNoteUpdateEvent create(String authSessionId, String tabId, String clientUUID,
|
||||
Map<String, String> authNotesFragment) {
|
||||
MapAuthenticationSessionAuthNoteUpdateEvent event = new MapAuthenticationSessionAuthNoteUpdateEvent();
|
||||
event.authSessionId = authSessionId;
|
||||
event.tabId = tabId;
|
||||
event.clientUUID = clientUUID;
|
||||
event.authNotesFragment = new LinkedHashMap<>(authNotesFragment);
|
||||
return event;
|
||||
}
|
||||
|
||||
public String getAuthSessionId() {
|
||||
return authSessionId;
|
||||
}
|
||||
|
||||
public String getTabId() {
|
||||
return tabId;
|
||||
}
|
||||
|
||||
public String getClientUUID() {
|
||||
return clientUUID;
|
||||
}
|
||||
|
||||
public Map<String, String> getAuthNotesFragment() {
|
||||
return authNotesFragment;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return String.format("AuthenticationSessionAuthNoteUpdateEvent [ authSessionId=%s, tabId=%s, clientUUID=%s, authNotesFragment=%s ]",
|
||||
authSessionId, clientUUID, authNotesFragment);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,134 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
|
||||
import java.util.HashSet;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapAuthenticationSessionEntity {
|
||||
|
||||
private String clientUUID;
|
||||
|
||||
private String authUserId;
|
||||
|
||||
private String redirectUri;
|
||||
private String action;
|
||||
private Set<String> clientScopes = new HashSet<>();
|
||||
|
||||
private Map<String, AuthenticationSessionModel.ExecutionStatus> executionStatus = new ConcurrentHashMap<>();
|
||||
private String protocol;
|
||||
|
||||
private Map<String, String> clientNotes= new ConcurrentHashMap<>();;
|
||||
private Map<String, String> authNotes = new ConcurrentHashMap<>();;
|
||||
private Set<String> requiredActions = new HashSet<>();
|
||||
private Map<String, String> userSessionNotes = new ConcurrentHashMap<>();
|
||||
|
||||
public Map<String, String> getUserSessionNotes() {
|
||||
return userSessionNotes;
|
||||
}
|
||||
|
||||
public void setUserSessionNotes(Map<String, String> userSessionNotes) {
|
||||
this.userSessionNotes = userSessionNotes;
|
||||
}
|
||||
|
||||
public String getClientUUID() {
|
||||
return clientUUID;
|
||||
}
|
||||
|
||||
public void setClientUUID(String clientUUID) {
|
||||
this.clientUUID = clientUUID;
|
||||
}
|
||||
|
||||
public String getAuthUserId() {
|
||||
return authUserId;
|
||||
}
|
||||
|
||||
public void setAuthUserId(String authUserId) {
|
||||
this.authUserId = authUserId;
|
||||
}
|
||||
|
||||
public String getRedirectUri() {
|
||||
return redirectUri;
|
||||
}
|
||||
|
||||
public void setRedirectUri(String redirectUri) {
|
||||
this.redirectUri = redirectUri;
|
||||
}
|
||||
|
||||
public String getAction() {
|
||||
return action;
|
||||
}
|
||||
|
||||
public void setAction(String action) {
|
||||
this.action = action;
|
||||
}
|
||||
|
||||
public Set<String> getClientScopes() {
|
||||
return clientScopes;
|
||||
}
|
||||
|
||||
public void setClientScopes(Set<String> clientScopes) {
|
||||
this.clientScopes = clientScopes;
|
||||
}
|
||||
|
||||
public Set<String> getRequiredActions() {
|
||||
return requiredActions;
|
||||
}
|
||||
|
||||
public void setRequiredActions(Set<String> requiredActions) {
|
||||
this.requiredActions = requiredActions;
|
||||
}
|
||||
|
||||
public String getProtocol() {
|
||||
return protocol;
|
||||
}
|
||||
|
||||
public void setProtocol(String protocol) {
|
||||
this.protocol = protocol;
|
||||
}
|
||||
|
||||
public Map<String, String> getClientNotes() {
|
||||
return clientNotes;
|
||||
}
|
||||
|
||||
public void setClientNotes(Map<String, String> clientNotes) {
|
||||
this.clientNotes = clientNotes;
|
||||
}
|
||||
|
||||
public Map<String, String> getAuthNotes() {
|
||||
return authNotes;
|
||||
}
|
||||
|
||||
public void setAuthNotes(Map<String, String> authNotes) {
|
||||
this.authNotes = authNotes;
|
||||
}
|
||||
|
||||
public Map<String, AuthenticationSessionModel.ExecutionStatus> getExecutionStatus() {
|
||||
return executionStatus;
|
||||
}
|
||||
|
||||
public void setExecutionStatus(Map<String, AuthenticationSessionModel.ExecutionStatus> executionStatus) {
|
||||
this.executionStatus = executionStatus;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,128 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.sessions.AuthenticationSessionProvider;
|
||||
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapRootAuthenticationSessionAdapter extends AbstractRootAuthenticationSessionModel<MapRootAuthenticationSessionEntity> {
|
||||
|
||||
public MapRootAuthenticationSessionAdapter(KeycloakSession session, RealmModel realm, MapRootAuthenticationSessionEntity entity) {
|
||||
super(session, realm, entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getId() {
|
||||
return entity.getId().toString();
|
||||
}
|
||||
|
||||
@Override
|
||||
public RealmModel getRealm() {
|
||||
return session.realms().getRealm(entity.getRealmId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getTimestamp() {
|
||||
return entity.getTimestamp();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void setTimestamp(int timestamp) {
|
||||
entity.setTimestamp(timestamp);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, AuthenticationSessionModel> getAuthenticationSessions() {
|
||||
return entity.getAuthenticationSessions().entrySet()
|
||||
.stream()
|
||||
.collect(Collectors.toMap(Map.Entry::getKey,
|
||||
entry -> new MapAuthenticationSessionAdapter(session, this, entry.getKey(), entry.getValue())));
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationSessionModel getAuthenticationSession(ClientModel client, String tabId) {
|
||||
if (client == null || tabId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
AuthenticationSessionModel authSession = getAuthenticationSessions().get(tabId);
|
||||
if (authSession != null && client.equals(authSession.getClient())) {
|
||||
session.getContext().setAuthenticationSession(authSession);
|
||||
return authSession;
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationSessionModel createAuthenticationSession(ClientModel client) {
|
||||
Objects.requireNonNull(client, "The provided client can't be null!");
|
||||
|
||||
MapAuthenticationSessionEntity authSessionEntity = new MapAuthenticationSessionEntity();
|
||||
authSessionEntity.setClientUUID(client.getId());
|
||||
|
||||
String tabId = generateTabId();
|
||||
entity.getAuthenticationSessions().put(tabId, authSessionEntity);
|
||||
|
||||
// Update our timestamp when adding new authenticationSession
|
||||
entity.setTimestamp(Time.currentTime());
|
||||
|
||||
MapAuthenticationSessionAdapter authSession = new MapAuthenticationSessionAdapter(session, this, tabId, authSessionEntity);
|
||||
session.getContext().setAuthenticationSession(authSession);
|
||||
return authSession;
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeAuthenticationSessionByTabId(String tabId) {
|
||||
if (entity.removeAuthenticationSession(tabId) != null) {
|
||||
if (entity.getAuthenticationSessions().isEmpty()) {
|
||||
MapRootAuthenticationSessionProvider authenticationSessionProvider =
|
||||
(MapRootAuthenticationSessionProvider) session.authenticationSessions();
|
||||
authenticationSessionProvider.tx.remove(entity.getId());
|
||||
} else {
|
||||
entity.setTimestamp(Time.currentTime());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void restartSession(RealmModel realm) {
|
||||
entity.clearAuthenticationSessions();
|
||||
entity.setTimestamp(Time.currentTime());
|
||||
}
|
||||
|
||||
public void setUpdated(boolean updated) {
|
||||
entity.updated |= updated;
|
||||
}
|
||||
|
||||
private String generateTabId() {
|
||||
return Base64Url.encode(KeycloakModelUtils.generateSecret(8));
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapRootAuthenticationSessionEntity extends AbstractRootAuthenticationSessionEntity<UUID> {
|
||||
|
||||
protected MapRootAuthenticationSessionEntity() {
|
||||
super();
|
||||
}
|
||||
|
||||
public MapRootAuthenticationSessionEntity(UUID id, String realmId) {
|
||||
super(id, realmId);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,187 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.cluster.ClusterProvider;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.ModelDuplicateException;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.map.common.Serialization;
|
||||
import org.keycloak.models.map.storage.MapKeycloakTransaction;
|
||||
import org.keycloak.models.map.storage.MapStorage;
|
||||
import org.keycloak.models.utils.RealmInfoUtil;
|
||||
import org.keycloak.sessions.AuthenticationSessionCompoundId;
|
||||
import org.keycloak.sessions.AuthenticationSessionProvider;
|
||||
import org.keycloak.sessions.RootAuthenticationSessionModel;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Function;
|
||||
import java.util.function.Predicate;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static org.keycloak.common.util.StackUtil.getShortStackTrace;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapRootAuthenticationSessionProvider implements AuthenticationSessionProvider {
|
||||
|
||||
private static final Logger LOG = Logger.getLogger(MapRootAuthenticationSessionProvider.class);
|
||||
private final KeycloakSession session;
|
||||
protected final MapKeycloakTransaction<UUID, MapRootAuthenticationSessionEntity> tx;
|
||||
private final MapStorage<UUID, MapRootAuthenticationSessionEntity> sessionStore;
|
||||
|
||||
private static final Predicate<MapRootAuthenticationSessionEntity> ALWAYS_FALSE = role -> false;
|
||||
private static final String AUTHENTICATION_SESSION_EVENTS = "AUTHENTICATION_SESSION_EVENTS";
|
||||
|
||||
public MapRootAuthenticationSessionProvider(KeycloakSession session, MapStorage<UUID, MapRootAuthenticationSessionEntity> sessionStore) {
|
||||
this.session = session;
|
||||
this.sessionStore = sessionStore;
|
||||
this.tx = new MapKeycloakTransaction<>(sessionStore);
|
||||
|
||||
session.getTransactionManager().enlistAfterCompletion(tx);
|
||||
}
|
||||
|
||||
private Function<MapRootAuthenticationSessionEntity, RootAuthenticationSessionModel> entityToAdapterFunc(RealmModel realm) {
|
||||
// Clone entity before returning back, to avoid giving away a reference to the live object to the caller
|
||||
|
||||
return origEntity -> new MapRootAuthenticationSessionAdapter(session, realm, registerEntityForChanges(origEntity));
|
||||
}
|
||||
|
||||
private MapRootAuthenticationSessionEntity registerEntityForChanges(MapRootAuthenticationSessionEntity origEntity) {
|
||||
MapRootAuthenticationSessionEntity res = tx.get(origEntity.getId(), id -> Serialization.from(origEntity));
|
||||
tx.putIfChanged(origEntity.getId(), res, MapRootAuthenticationSessionEntity::isUpdated);
|
||||
return res;
|
||||
}
|
||||
|
||||
private Predicate<MapRootAuthenticationSessionEntity> entityRealmFilter(String realmId) {
|
||||
if (realmId == null) {
|
||||
return MapRootAuthenticationSessionProvider.ALWAYS_FALSE;
|
||||
}
|
||||
return entity -> Objects.equals(realmId, entity.getRealmId());
|
||||
}
|
||||
|
||||
@Override
|
||||
public RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm) {
|
||||
Objects.requireNonNull(realm, "The provided realm can't be null!");
|
||||
return createRootAuthenticationSession(realm, null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm, String id) {
|
||||
Objects.requireNonNull(realm, "The provided realm can't be null!");
|
||||
|
||||
final UUID entityId = id == null ? UUID.randomUUID() : UUID.fromString(id);
|
||||
|
||||
LOG.tracef("createRootAuthenticationSession(%s)%s", realm.getName(), getShortStackTrace());
|
||||
|
||||
// create map authentication session entity
|
||||
MapRootAuthenticationSessionEntity entity = new MapRootAuthenticationSessionEntity(entityId, realm.getId());
|
||||
entity.setRealmId(realm.getId());
|
||||
entity.setTimestamp(Time.currentTime());
|
||||
|
||||
if (tx.get(entity.getId(), sessionStore::get) != null) {
|
||||
throw new ModelDuplicateException("Root authentication session exists: " + entity.getId());
|
||||
}
|
||||
|
||||
tx.putIfAbsent(entity.getId(), entity);
|
||||
|
||||
return entityToAdapterFunc(realm).apply(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public RootAuthenticationSessionModel getRootAuthenticationSession(RealmModel realm, String authenticationSessionId) {
|
||||
Objects.requireNonNull(realm, "The provided realm can't be null!");
|
||||
if (authenticationSessionId == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
LOG.tracef("getRootAuthenticationSession(%s, %s)%s", realm.getName(), authenticationSessionId, getShortStackTrace());
|
||||
|
||||
MapRootAuthenticationSessionEntity entity = tx.get(UUID.fromString(authenticationSessionId), sessionStore::get);
|
||||
return (entity == null || !entityRealmFilter(realm.getId()).test(entity))
|
||||
? null
|
||||
: entityToAdapterFunc(realm).apply(entity);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeRootAuthenticationSession(RealmModel realm, RootAuthenticationSessionModel authenticationSession) {
|
||||
Objects.requireNonNull(authenticationSession, "The provided root authentication session can't be null!");
|
||||
tx.remove(UUID.fromString(authenticationSession.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void removeExpired(RealmModel realm) {
|
||||
Objects.requireNonNull(realm, "The provided realm can't be null!");
|
||||
LOG.debugf("Removing expired sessions");
|
||||
|
||||
int expired = Time.currentTime() - RealmInfoUtil.getDettachedClientSessionLifespan(realm);
|
||||
|
||||
List<UUID> sessionIds = sessionStore.entrySet().stream()
|
||||
.filter(entity -> entityRealmFilter(realm.getId()).test(entity.getValue()))
|
||||
.filter(entity -> entity.getValue().getTimestamp() < expired)
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
LOG.debugf("Removed %d expired authentication sessions for realm '%s'", sessionIds.size(), realm.getName());
|
||||
|
||||
sessionIds.forEach(tx::remove);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRealmRemoved(RealmModel realm) {
|
||||
Objects.requireNonNull(realm, "The provided realm can't be null!");
|
||||
sessionStore.entrySet().stream()
|
||||
.filter(entity -> entityRealmFilter(realm.getId()).test(entity.getValue()))
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toList())
|
||||
.forEach(tx::remove);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onClientRemoved(RealmModel realm, ClientModel client) {
|
||||
|
||||
}
|
||||
|
||||
@Override
|
||||
public void updateNonlocalSessionAuthNotes(AuthenticationSessionCompoundId compoundId, Map<String, String> authNotesFragment) {
|
||||
if (compoundId == null) {
|
||||
return;
|
||||
}
|
||||
Objects.requireNonNull(authNotesFragment, "The provided authentication's notes map can't be null!");
|
||||
|
||||
ClusterProvider cluster = session.getProvider(ClusterProvider.class);
|
||||
cluster.notify(
|
||||
AUTHENTICATION_SESSION_EVENTS,
|
||||
MapAuthenticationSessionAuthNoteUpdateEvent.create(compoundId.getRootSessionId(), compoundId.getTabId(),
|
||||
compoundId.getClientUUID(), authNotesFragment),
|
||||
true,
|
||||
ClusterProvider.DCNotify.ALL_BUT_LOCAL_DC
|
||||
);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void close() {
|
||||
|
||||
}
|
||||
}
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
* and other contributors as indicated by the @author tags.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
package org.keycloak.models.map.authSession;
|
||||
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.KeycloakSessionFactory;
|
||||
import org.keycloak.models.map.common.AbstractMapProviderFactory;
|
||||
import org.keycloak.models.map.storage.MapStorage;
|
||||
import org.keycloak.models.map.storage.MapStorageProvider;
|
||||
import org.keycloak.sessions.AuthenticationSessionProvider;
|
||||
import org.keycloak.sessions.AuthenticationSessionProviderFactory;
|
||||
|
||||
import java.util.UUID;
|
||||
|
||||
/**
|
||||
* @author <a href="mailto:mkanis@redhat.com">Martin Kanis</a>
|
||||
*/
|
||||
public class MapRootAuthenticationSessionProviderFactory extends AbstractMapProviderFactory<AuthenticationSessionProvider>
|
||||
implements AuthenticationSessionProviderFactory {
|
||||
|
||||
private MapStorage<UUID, MapRootAuthenticationSessionEntity> store;
|
||||
|
||||
@Override
|
||||
public void postInit(KeycloakSessionFactory factory) {
|
||||
MapStorageProvider sp = (MapStorageProvider) factory.getProviderFactory(MapStorageProvider.class);
|
||||
this.store = sp.getStorage("sessions", UUID.class, MapRootAuthenticationSessionEntity.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public AuthenticationSessionProvider create(KeycloakSession session) {
|
||||
return new MapRootAuthenticationSessionProvider(session, store);
|
||||
}
|
||||
}
|
|
@ -115,7 +115,7 @@ public class MapKeycloakTransaction<K, V> implements KeycloakTransaction {
|
|||
* Adds a given task if not exists for the given key
|
||||
*/
|
||||
private void addTask(MapOperation op, K key, V value) {
|
||||
log.tracev("Adding operation {0} for {1}", op, key);
|
||||
log.tracef("Adding operation %s for %s @ %08x", op, key, System.identityHashCode(value));
|
||||
|
||||
K taskKey = key;
|
||||
tasks.merge(taskKey, op.taskFor(key, value), MapTaskCompose::new);
|
||||
|
@ -149,7 +149,7 @@ public class MapKeycloakTransaction<K, V> implements KeycloakTransaction {
|
|||
}
|
||||
|
||||
public void putIfChanged(K key, V value, Predicate<V> shouldPut) {
|
||||
log.tracev("Adding operation UPDATE_IF_CHANGED for {0}", key);
|
||||
log.tracef("Adding operation UPDATE_IF_CHANGED for %s @ %08x", key, System.identityHashCode(value));
|
||||
|
||||
K taskKey = key;
|
||||
MapTaskWithValue<K, V> op = new MapTaskWithValue<K, V>(value) {
|
||||
|
|
|
@ -0,0 +1,18 @@
|
|||
#
|
||||
# Copyright 2020 Red Hat, Inc. and/or its affiliates
|
||||
# and other contributors as indicated by the @author tags.
|
||||
#
|
||||
# Licensed under the Apache License, Version 2.0 (the "License");
|
||||
# you may not use this file except in compliance with the License.
|
||||
# You may obtain a copy of the License at
|
||||
#
|
||||
# http://www.apache.org/licenses/LICENSE-2.0
|
||||
#
|
||||
# Unless required by applicable law or agreed to in writing, software
|
||||
# distributed under the License is distributed on an "AS IS" BASIS,
|
||||
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
# See the License for the specific language governing permissions and
|
||||
# limitations under the License.
|
||||
#
|
||||
|
||||
org.keycloak.models.map.authSession.MapRootAuthenticationSessionProviderFactory
|
|
@ -37,104 +37,171 @@ public interface AuthenticationSessionModel extends CommonClientSessionModel {
|
|||
*/
|
||||
String getTabId();
|
||||
|
||||
/**
|
||||
* Returns the root authentication session that is parent of this authentication session.
|
||||
* @return {@code RootAuthenticationSessionModel}
|
||||
*/
|
||||
RootAuthenticationSessionModel getParentSession();
|
||||
|
||||
/**
|
||||
* Returns execution status of the authentication session.
|
||||
* @return {@code Map<String, ExecutionStatus>} Never returns {@code null}.
|
||||
*/
|
||||
Map<String, ExecutionStatus> getExecutionStatus();
|
||||
|
||||
/**
|
||||
* Sets execution status of the authentication session.
|
||||
* @param authenticator {@code String} Can't be {@code null}.
|
||||
* @param status {@code ExecutionStatus} Can't be {@code null}.
|
||||
*/
|
||||
void setExecutionStatus(String authenticator, ExecutionStatus status);
|
||||
|
||||
/**
|
||||
* Clears execution status of the authentication session.
|
||||
*/
|
||||
void clearExecutionStatus();
|
||||
|
||||
/**
|
||||
* Returns authenticated user that is associated to the authentication session.
|
||||
* @return {@code UserModel} or null if there's no authenticated user.
|
||||
*/
|
||||
UserModel getAuthenticatedUser();
|
||||
|
||||
/**
|
||||
* Sets authenticated user that is associated to the authentication session.
|
||||
* @param user {@code UserModel} If {@code null} then {@code null} will be set to the authenticated user.
|
||||
*/
|
||||
void setAuthenticatedUser(UserModel user);
|
||||
|
||||
/**
|
||||
* Required actions that are attached to this client session.
|
||||
*
|
||||
* @return
|
||||
* Returns required actions that are attached to this client session.
|
||||
* @return {@code Set<String>} Never returns {@code null}.
|
||||
*/
|
||||
Set<String> getRequiredActions();
|
||||
|
||||
/**
|
||||
* Adds a required action to the authentication session.
|
||||
* @param action {@code String} Can't be {@code null}.
|
||||
*/
|
||||
void addRequiredAction(String action);
|
||||
|
||||
/**
|
||||
* Removes a required action from the authentication session.
|
||||
* @param action {@code String} Can't be {@code null}.
|
||||
*/
|
||||
void removeRequiredAction(String action);
|
||||
|
||||
/**
|
||||
* Adds a required action to the authentication session.
|
||||
* @param action {@code UserModel.RequiredAction} Can't be {@code null}.
|
||||
*/
|
||||
void addRequiredAction(UserModel.RequiredAction action);
|
||||
|
||||
/**
|
||||
* Removes a required action from the authentication session.
|
||||
* @param action {@code UserModel.RequiredAction} Can't be {@code null}.
|
||||
*/
|
||||
void removeRequiredAction(UserModel.RequiredAction action);
|
||||
|
||||
|
||||
/**
|
||||
* Sets the given user session note to the given value. User session notes are notes
|
||||
* you want be applied to the UserSessionModel when the client session is attached to it.
|
||||
* Sets the given user session note to the given value. User session notes are notes
|
||||
* you want be applied to the UserSessionModel when the client session is attached to it.
|
||||
* @param name {@code String} If {@code null} is provided the method won't have an effect.
|
||||
* @param value {@code String} If {@code null} is provided the method won't have an effect.
|
||||
*/
|
||||
void setUserSessionNote(String name, String value);
|
||||
|
||||
/**
|
||||
* Retrieves value of given user session note. User session notes are notes
|
||||
* you want be applied to the UserSessionModel when the client session is attached to it.
|
||||
* Retrieves value of given user session note. User session notes are notes
|
||||
* you want be applied to the UserSessionModel when the client session is attached to it.
|
||||
* @return {@code Map<String, String>} never returns {@code null}
|
||||
*/
|
||||
Map<String, String> getUserSessionNotes();
|
||||
|
||||
/**
|
||||
* Clears all user session notes. User session notes are notes
|
||||
* you want be applied to the UserSessionModel when the client session is attached to it.
|
||||
* Clears all user session notes. User session notes are notes
|
||||
* you want be applied to the UserSessionModel when the client session is attached to it.
|
||||
*/
|
||||
void clearUserSessionNotes();
|
||||
|
||||
/**
|
||||
* Retrieves value of the given authentication note to the given value. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted
|
||||
* Retrieves value of the given authentication note to the given value. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted.
|
||||
* @param name {@code String} If {@code null} if provided then the method will return {@code null}.
|
||||
* @return {@code String} or {@code null} if no authentication note is set.
|
||||
*/
|
||||
String getAuthNote(String name);
|
||||
|
||||
/**
|
||||
* Sets the given authentication note to the given value. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted
|
||||
* Sets the given authentication note to the given value. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted.
|
||||
* @param name {@code String} If {@code null} is provided the method won't have an effect.
|
||||
* @param value {@code String} If {@code null} is provided the method won't have an effect.
|
||||
*/
|
||||
void setAuthNote(String name, String value);
|
||||
|
||||
/**
|
||||
* Removes the given authentication note. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted
|
||||
* Removes the given authentication note. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted.
|
||||
* @param name {@code String} If {@code null} is provided the method won't have an effect.
|
||||
*/
|
||||
void removeAuthNote(String name);
|
||||
|
||||
/**
|
||||
* Clears all authentication note. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted
|
||||
* Clears all authentication note. Authentication notes are notes
|
||||
* used typically by authenticators and authentication flows. They are cleared when
|
||||
* authentication session is restarted.
|
||||
*/
|
||||
void clearAuthNotes();
|
||||
|
||||
/**
|
||||
* Retrieves value of the given client note to the given value. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* Retrieves value of the given client note to the given value. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* @param name {@code String} If {@code null} if provided then the method will return {@code null}.
|
||||
* @return {@code String} or {@code null} if no client's note is set.
|
||||
*/
|
||||
String getClientNote(String name);
|
||||
|
||||
/**
|
||||
* Sets the given client note to the given value. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* Sets the given client note to the given value. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* @param name {@code String} If {@code null} is provided the method won't have an effect.
|
||||
* @param value {@code String} If {@code null} is provided the method won't have an effect.
|
||||
*/
|
||||
void setClientNote(String name, String value);
|
||||
|
||||
/**
|
||||
* Removes the given client note. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* Removes the given client note. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* @param name {@code String} If {@code null} is provided the method won't have an effect.
|
||||
*/
|
||||
void removeClientNote(String name);
|
||||
|
||||
/**
|
||||
* Retrieves the (name, value) map of client notes. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* Retrieves the (name, value) map of client notes. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* @return {@code Map<String, String>} never returns {@code null}.
|
||||
*/
|
||||
Map<String, String> getClientNotes();
|
||||
|
||||
/**
|
||||
* Clears all client notes. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
* Clears all client notes. Client notes are notes
|
||||
* specific to client protocol. They are NOT cleared when authentication session is restarted.
|
||||
*/
|
||||
void clearClientNotes();
|
||||
|
||||
/**
|
||||
* Get client scope IDs
|
||||
* Gets client scope IDs from the authentication session.
|
||||
* @return {@code Set<String>} never returns {@code null}.
|
||||
*/
|
||||
Set<String> getClientScopes();
|
||||
|
||||
/**
|
||||
* Set client scope IDs
|
||||
* Sets client scope IDs to the authentication session.
|
||||
* @param clientScopes {@code Set<String>} Can't be {@code null}.
|
||||
*/
|
||||
void setClientScopes(Set<String> clientScopes);
|
||||
|
||||
|
|
|
@ -31,27 +31,72 @@ public interface AuthenticationSessionProvider extends Provider {
|
|||
/**
|
||||
* Creates and registers a new authentication session with random ID. Authentication session
|
||||
* entity will be prefilled with current timestamp, the given realm and client.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
* @return Returns created {@code RootAuthenticationSessionModel}. Never returns {@code null}.
|
||||
*/
|
||||
RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm);
|
||||
|
||||
RootAuthenticationSessionModel createRootAuthenticationSession(String id, RealmModel realm);
|
||||
/**
|
||||
* Creates a new root authentication session specified by the provided id and realm.
|
||||
* @param id {@code String} Id of newly created root authentication session. If {@code null} a random id will be generated.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
* @return Returns created {@code RootAuthenticationSessionModel}. Never returns {@code null}.
|
||||
* @deprecated Use {@link #createRootAuthenticationSession(RealmModel, String)} createRootAuthenticationSession} instead.
|
||||
*/
|
||||
@Deprecated
|
||||
default RootAuthenticationSessionModel createRootAuthenticationSession(String id, RealmModel realm) {
|
||||
return createRootAuthenticationSession(realm, id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new root authentication session specified by the provided realm and id.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
* @param id {@code String} Id of newly created root authentication session. If {@code null} a random id will be generated.
|
||||
* @return Returns created {@code RootAuthenticationSessionModel}. Never returns {@code null}.
|
||||
*/
|
||||
RootAuthenticationSessionModel createRootAuthenticationSession(RealmModel realm, String id);
|
||||
|
||||
/**
|
||||
* Returns the root authentication session specified by the provided realm and id.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
* @param authenticationSessionId {@code RootAuthenticationSessionModel} If {@code null} then {@code null} will be returned.
|
||||
* @return Returns found {@code RootAuthenticationSessionModel} or {@code null} if no root authentication session is found.
|
||||
*/
|
||||
RootAuthenticationSessionModel getRootAuthenticationSession(RealmModel realm, String authenticationSessionId);
|
||||
|
||||
/**
|
||||
* Removes provided root authentication session.
|
||||
* @param realm {@code RealmModel} Associated realm to the given root authentication session.
|
||||
* @param authenticationSession {@code RootAuthenticationSessionModel} Can't be {@code null}.
|
||||
*/
|
||||
void removeRootAuthenticationSession(RealmModel realm, RootAuthenticationSessionModel authenticationSession);
|
||||
|
||||
/**
|
||||
* Removes all expired root authentication sessions for the given realm.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
*/
|
||||
void removeExpired(RealmModel realm);
|
||||
|
||||
/**
|
||||
* Removes all associated root authentication sessions to the given realm which was removed.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
*/
|
||||
void onRealmRemoved(RealmModel realm);
|
||||
|
||||
/**
|
||||
* Removes all associated root authentication sessions to the given realm and client which was removed.
|
||||
* @param realm {@code RealmModel} Can't be {@code null}.
|
||||
* @param client {@code ClientModel} Can't be {@code null}.
|
||||
*/
|
||||
void onClientRemoved(RealmModel realm, ClientModel client);
|
||||
|
||||
/**
|
||||
* Requests update of authNotes of a root authentication session that is not owned
|
||||
* by this instance but might exist somewhere in the cluster.
|
||||
*
|
||||
* @param compoundId
|
||||
* @param authNotesFragment Map with authNote values. Auth note is removed if the corresponding value in the map is {@code null}.
|
||||
* @param compoundId {@code AuthenticationSessionCompoundId} The method has no effect if {@code null}.
|
||||
* @param authNotesFragment {@code Map<String, String>} Map with authNote values.
|
||||
* Auth note is removed if the corresponding value in the map is {@code null}. Map itself can't be {@code null}.
|
||||
*/
|
||||
void updateNonlocalSessionAuthNotes(AuthenticationSessionCompoundId compoundId, Map<String, String> authNotesFragment);
|
||||
|
||||
|
||||
}
|
||||
|
|
|
@ -23,50 +23,70 @@ import org.keycloak.models.ClientModel;
|
|||
import org.keycloak.models.RealmModel;
|
||||
|
||||
/**
|
||||
* Represents usually one browser session with potentially many browser tabs. Every browser tab is represented by {@link AuthenticationSessionModel}
|
||||
* of different client.
|
||||
* Represents usually one browser session with potentially many browser tabs. Every browser tab is represented by
|
||||
* {@link AuthenticationSessionModel} of different client.
|
||||
*
|
||||
* @author <a href="mailto:mposolda@redhat.com">Marek Posolda</a>
|
||||
*/
|
||||
public interface RootAuthenticationSessionModel {
|
||||
|
||||
/**
|
||||
* Returns id of the root authentication session.
|
||||
* @return {@code String}
|
||||
*/
|
||||
String getId();
|
||||
RealmModel getRealm();
|
||||
|
||||
int getTimestamp();
|
||||
void setTimestamp(int timestamp);
|
||||
|
||||
|
||||
/**
|
||||
* Returns realm associated to the root authentication session.
|
||||
* @return {@code RealmModel}
|
||||
*/
|
||||
RealmModel getRealm();
|
||||
|
||||
/**
|
||||
* Returns timestamp when the root authentication session was created or updated.
|
||||
* @return {@code int}
|
||||
*/
|
||||
int getTimestamp();
|
||||
|
||||
/**
|
||||
* Sets a timestamp when the root authentication session was created or updated.
|
||||
* @param timestamp {@code int}
|
||||
*/
|
||||
void setTimestamp(int timestamp);
|
||||
|
||||
/**
|
||||
* Returns authentication sessions for the root authentication session.
|
||||
* Key is tabId, Value is AuthenticationSessionModel.
|
||||
* @return authentication sessions or empty map if no authenticationSessions presents. Never return null.
|
||||
* @return {@code Map<String, AuthenticationSessionModel>} authentication sessions or empty map if no
|
||||
* authentication sessions are present. Never return null.
|
||||
*/
|
||||
Map<String, AuthenticationSessionModel> getAuthenticationSessions();
|
||||
|
||||
|
||||
/**
|
||||
* @return authentication session for particular client and tab or null if it doesn't yet exists.
|
||||
* Returns an authentication session for the particular client and tab or null if it doesn't yet exists.
|
||||
* @param client {@code ClientModel} If {@code null} is provided the method will return {@code null}.
|
||||
* @param tabId {@code String} If {@code null} is provided the method will return {@code null}.
|
||||
* @return {@code AuthenticationSessionModel} or {@code null} in no authentication session is found.
|
||||
*/
|
||||
AuthenticationSessionModel getAuthenticationSession(ClientModel client, String tabId);
|
||||
|
||||
|
||||
/**
|
||||
* Create new authentication session and returns it. Overwrites existing session for particular client if already exists.
|
||||
*
|
||||
* @param client
|
||||
* @return non-null fresh authentication session
|
||||
* Create a new authentication session and returns it. Overwrites existing session for particular client if already exists.
|
||||
* @param client {@code ClientModel} Can't be {@code null}.
|
||||
* @return {@code AuthenticationSessionModel} non-null fresh authentication session. Never returns {@code null}.
|
||||
*/
|
||||
AuthenticationSessionModel createAuthenticationSession(ClientModel client);
|
||||
|
||||
/**
|
||||
* Removes authentication session from root authentication session.
|
||||
* Removes the authentication session specified by tab id from the root authentication session.
|
||||
* If there's no child authentication session left in the root authentication session, it's removed as well.
|
||||
* @param tabId String
|
||||
* @param tabId {@code String} Can't be {@code null}.
|
||||
*/
|
||||
void removeAuthenticationSessionByTabId(String tabId);
|
||||
|
||||
/**
|
||||
* Will completely restart whole state of authentication session. It will just keep same ID. It will setup it with provided realm.
|
||||
* @param realm {@code RealmModel} Associated realm to the root authentication session.
|
||||
*/
|
||||
void restartSession(RealmModel realm);
|
||||
|
||||
|
|
|
@ -306,7 +306,7 @@ public class AuthorizationTokenService {
|
|||
|
||||
if (rootAuthSession == null) {
|
||||
if (userSessionModel.getUser().getServiceAccountClientLink() == null) {
|
||||
rootAuthSession = keycloakSession.authenticationSessions().createRootAuthenticationSession(userSessionModel.getId(), realm);
|
||||
rootAuthSession = keycloakSession.authenticationSessions().createRootAuthenticationSession(realm, userSessionModel.getId());
|
||||
} else {
|
||||
// if the user session is associated with a service account
|
||||
rootAuthSession = new AuthenticationSessionManager(keycloakSession).createAuthenticationSession(realm, false);
|
||||
|
|
|
@ -196,7 +196,7 @@ public abstract class AuthorizationEndpointBase {
|
|||
AuthenticationManager.backchannelLogout(session, userSession, true);
|
||||
} else {
|
||||
String userSessionId = userSession.getId();
|
||||
rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(userSessionId, realm);
|
||||
rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realm, userSessionId);
|
||||
authSession = rootAuthSession.createAuthenticationSession(client);
|
||||
logger.debugf("Sent request to authz endpoint. We don't have root authentication session with ID '%s' but we have userSession." +
|
||||
"Re-created root authentication session with same ID. Client is: %s . New authentication session tab ID: %s", userSessionId, client.getClientId(), authSession.getTabId());
|
||||
|
|
|
@ -314,7 +314,7 @@ public class AuthenticationManager {
|
|||
}
|
||||
|
||||
if (rootLogoutSession == null) {
|
||||
rootLogoutSession = session.authenticationSessions().createRootAuthenticationSession(authSessionId, realm);
|
||||
rootLogoutSession = session.authenticationSessions().createRootAuthenticationSession(realm, authSessionId);
|
||||
}
|
||||
if (browserCookie && !browserCookiePresent) {
|
||||
// Update cookie if needed
|
||||
|
|
|
@ -17,31 +17,22 @@
|
|||
|
||||
package org.keycloak.services.managers;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.sessions.CommonClientSessionModel;
|
||||
import org.keycloak.sessions.RootAuthenticationSessionModel;
|
||||
|
||||
import java.security.MessageDigest;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.UUID;
|
||||
import java.util.function.Supplier;
|
||||
|
||||
import javax.crypto.SecretKey;
|
||||
|
||||
import org.jboss.logging.Logger;
|
||||
import org.keycloak.common.util.Base64Url;
|
||||
import org.keycloak.common.util.Time;
|
||||
import org.keycloak.events.Details;
|
||||
import org.keycloak.events.EventBuilder;
|
||||
import org.keycloak.jose.jwe.JWEException;
|
||||
import org.keycloak.models.AuthenticatedClientSessionModel;
|
||||
import org.keycloak.models.ClientModel;
|
||||
import org.keycloak.models.CodeToTokenStoreProvider;
|
||||
import org.keycloak.models.KeycloakSession;
|
||||
import org.keycloak.models.RealmModel;
|
||||
import org.keycloak.models.UserSessionModel;
|
||||
import org.keycloak.models.utils.KeycloakModelUtils;
|
||||
import org.keycloak.sessions.CommonClientSessionModel;
|
||||
import org.keycloak.sessions.AuthenticationSessionModel;
|
||||
import org.keycloak.util.TokenUtil;
|
||||
|
||||
/**
|
||||
* TODO: Remove this and probably also ClientSessionParser. It's uneccessary genericity and abstraction, which is not needed anymore when clientSessionModel was fully removed.
|
||||
*
|
||||
|
@ -110,6 +101,18 @@ class CodeGenerateUtil {
|
|||
if (nextCode == null) {
|
||||
String actionId = Base64Url.encode(KeycloakModelUtils.generateSecret());
|
||||
authSession.setAuthNote(ACTIVE_CODE, actionId);
|
||||
|
||||
// We need to set the active code to the authSession in the separate sub-transaction as well
|
||||
// to make sure the change is committed if the main transaction is rolled back later.
|
||||
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession currentSession) -> {
|
||||
final RootAuthenticationSessionModel rootAuthenticationSession = currentSession.authenticationSessions()
|
||||
.getRootAuthenticationSession(authSession.getRealm(), authSession.getParentSession().getId());
|
||||
AuthenticationSessionModel authenticationSession = rootAuthenticationSession == null ? null : rootAuthenticationSession
|
||||
.getAuthenticationSession(authSession.getClient(), authSession.getTabId());
|
||||
if (authenticationSession != null) {
|
||||
authenticationSession.setAuthNote(ACTIVE_CODE, actionId);
|
||||
}
|
||||
});
|
||||
nextCode = actionId;
|
||||
} else {
|
||||
logger.debug("Code already generated for authentication session, using same code");
|
||||
|
@ -135,6 +138,15 @@ class CodeGenerateUtil {
|
|||
|
||||
authSession.removeAuthNote(ACTIVE_CODE);
|
||||
|
||||
// We need to remove the active code from the authSession in the separate sub-transaction as well
|
||||
// to make sure the change is committed if the main transaction is rolled back later.
|
||||
KeycloakModelUtils.runJobInTransaction(session.getKeycloakSessionFactory(), (KeycloakSession currentSession) -> {
|
||||
AuthenticationSessionModel authenticationSession = currentSession.authenticationSessions()
|
||||
.getRootAuthenticationSession(authSession.getRealm(), authSession.getParentSession().getId())
|
||||
.getAuthenticationSession(authSession.getClient(), authSession.getTabId());
|
||||
authenticationSession.removeAuthNote(ACTIVE_CODE);
|
||||
});
|
||||
|
||||
return MessageDigest.isEqual(code.getBytes(), activeCode.getBytes());
|
||||
}
|
||||
|
||||
|
|
|
@ -309,7 +309,7 @@ public class IdentityBrokerService implements IdentityProvider.AuthenticationCal
|
|||
// Auth session with ID corresponding to our userSession may already exists in some rare cases (EG. if some client tried to login in another browser tab with "prompt=login")
|
||||
RootAuthenticationSessionModel rootAuthSession = session.authenticationSessions().getRootAuthenticationSession(realmModel, userSession.getId());
|
||||
if (rootAuthSession == null) {
|
||||
rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(userSession.getId(), realmModel);
|
||||
rootAuthSession = session.authenticationSessions().createRootAuthenticationSession(realmModel, userSession.getId());
|
||||
}
|
||||
|
||||
AuthenticationSessionModel authSession = rootAuthSession.createAuthenticationSession(client);
|
||||
|
|
|
@ -56,6 +56,10 @@
|
|||
"provider": "${keycloak.role.provider:jpa}"
|
||||
},
|
||||
|
||||
"authenticationSessions": {
|
||||
"provider": "${keycloak.authSession.provider:infinispan}"
|
||||
},
|
||||
|
||||
"mapStorage": {
|
||||
"provider": "${keycloak.mapStorage.provider:concurrenthashmap}",
|
||||
"concurrenthashmap": {
|
||||
|
|
|
@ -30,6 +30,10 @@
|
|||
"provider": "${keycloak.role.provider:jpa}"
|
||||
},
|
||||
|
||||
"authenticationSessions": {
|
||||
"provider": "${keycloak.authSession.provider:infinispan}"
|
||||
},
|
||||
|
||||
"mapStorage": {
|
||||
"provider": "${keycloak.mapStorage.provider:concurrenthashmap}",
|
||||
"concurrenthashmap": {
|
||||
|
|
Loading…
Reference in a new issue