/*
* Copyright (C) 2014 Red Hat, Inc. and/or its affiliates.
*
* 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.jboss.errai.security.keycloak;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.AUDIENCE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.BIRTHDATE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.COUNTRY;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.EMAIL_VERIFIED;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.FORMATTED_ADDRESS;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.GENDER;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.LOCALE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.LOCALITY;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.MIDDLE_NAME;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.NAME;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.NICKNAME;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.PHONENUMBER;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.PHONENUMBER_VERIFIED;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.PICTURE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.POSTAL_CODE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.PREFERRED_USERNAME;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.PROFILE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.REGION;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.STREET_ADDRESS;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.SUBJECT;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.WEBSITE;
import static org.jboss.errai.security.keycloak.properties.KeycloakPropertyNames.ZONE_INFO;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import javax.enterprise.context.SessionScoped;
import javax.inject.Inject;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import org.jboss.errai.bus.server.annotations.Service;
import org.jboss.errai.bus.server.api.RpcContext;
import org.jboss.errai.security.keycloak.extension.Filtered;
import org.jboss.errai.security.shared.api.Role;
import org.jboss.errai.security.shared.api.RoleImpl;
import org.jboss.errai.security.shared.api.identity.User;
import org.jboss.errai.security.shared.api.identity.User.StandardUserProperties;
import org.jboss.errai.security.shared.api.identity.UserImpl;
import org.jboss.errai.security.shared.exception.AlreadyLoggedInException;
import org.jboss.errai.security.shared.exception.AuthenticationException;
import org.jboss.errai.security.shared.exception.FailedAuthenticationException;
import org.jboss.errai.security.shared.service.AuthenticationService;
import org.keycloak.KeycloakSecurityContext;
import org.keycloak.representations.AccessToken;
import org.keycloak.representations.AddressClaimSet;
/**
* <p>
* An {@link AuthenticationService} implementation that integrates with Keycloak. This
* implementation optionally wraps another {@link AuthenticationService} so that an app can have
* local and foreign user authentication.
*
* <p>
* Some important behaviour of this implementation:
* <ul>
* <li>The {@link #login(String, String)} method throws an {@link FailedAuthenticationException} if
* there is no wrapped {@link AuthenticationService}.
* <li>Attempting to login (through Keycloak or the wrapped service) while a user is already logged
* in causes an exception.
* <li>After a Keycloak login, what properties the {@link User} has depends on which properties have
* been enabled in Keycloak. The user instance returned will have all values from the Keycloak
* {@link AccessToken} pertaining to the user that were not null.
*
* @author Max Barkley <mbarkley@redhat.com>
* @author Christian Sadilek <csadilek@redhat.com>
*/
@Service
@SessionScoped
public class KeycloakAuthenticationService implements AuthenticationService, Serializable {
private static class KeycloakProperty {
final String name;
final String value;
KeycloakProperty(final String name, final String value) {
this.name = name;
this.value = value;
}
boolean hasValue() {
return value != null;
}
}
private static final long serialVersionUID = 1L;
@Inject
@Filtered
private AuthenticationService wrappedAuthService;
private User keycloakUser;
private KeycloakSecurityContext keycloakSecurityContext;
@Override
public User login(final String username, final String password) {
if (!keycloakIsLoggedIn()) {
return wrappedAuthService.login(username, password);
}
else {
throw new AlreadyLoggedInException("Already logged in through Keycloak.");
}
}
@Override
public boolean isLoggedIn() {
return keycloakIsLoggedIn() || wrappedAuthService.isLoggedIn();
}
private boolean keycloakIsLoggedIn() {
return keycloakSecurityContext != null && keycloakSecurityContext.getToken() != null;
}
@Override
public void logout() {
if (keycloakIsLoggedIn()) {
keycloakLogout();
try {
if (RpcContext.getMessage() != null)
((HttpServletRequest) RpcContext.getServletRequest()).logout();
} catch (ServletException e) {
throw new AuthenticationException("An error occurred while attempting to log out of Keycloak.");
}
}
else if (wrappedAuthService.isLoggedIn()) {
wrappedAuthService.logout();
}
}
private void keycloakLogout() {
setSecurityContext(null);
}
@Override
public User getUser() {
if (keycloakIsLoggedIn()) {
return getKeycloakUser();
}
else if (wrappedAuthService.isLoggedIn()) {
return wrappedAuthService.getUser();
}
else {
return User.ANONYMOUS;
}
}
private User getKeycloakUser() {
if (!keycloakIsLoggedIn()) {
throw new IllegalStateException(
"Cannot call getKeycloakUser if not logged in through Keycloak.");
}
if (keycloakUser == null) {
keycloakUser = createKeycloakUser(keycloakSecurityContext.getToken());
}
return keycloakUser;
}
protected User createKeycloakUser(final AccessToken accessToken) {
final User user = new UserImpl(accessToken.getPreferredUsername(), createRoles(accessToken));
final Collection<KeycloakProperty> properties = getKeycloakUserProperties(accessToken);
for (KeycloakProperty property : properties) {
if (property.hasValue()) {
user.setProperty(property.name, property.value);
}
}
return user;
}
private Collection<KeycloakProperty> getKeycloakUserProperties(final AccessToken accessToken) {
final Collection<KeycloakProperty> properties = new ArrayList<KeycloakAuthenticationService.KeycloakProperty>();
properties.add(new KeycloakProperty(StandardUserProperties.FIRST_NAME, accessToken.getGivenName()));
properties.add(new KeycloakProperty(StandardUserProperties.LAST_NAME, accessToken.getFamilyName()));
properties.add(new KeycloakProperty(StandardUserProperties.EMAIL, accessToken.getEmail()));
properties.add(new KeycloakProperty(BIRTHDATE, accessToken.getBirthdate()));
properties.add(new KeycloakProperty(GENDER, accessToken.getGender()));
properties.add(new KeycloakProperty(LOCALE, accessToken.getLocale()));
properties.add(new KeycloakProperty(MIDDLE_NAME, accessToken.getMiddleName()));
properties.add(new KeycloakProperty(NAME, accessToken.getName()));
properties.add(new KeycloakProperty(NICKNAME, accessToken.getNickName()));
properties.add(new KeycloakProperty(PHONENUMBER, accessToken.getPhoneNumber()));
properties.add(new KeycloakProperty(PICTURE, accessToken.getPicture()));
properties.add(new KeycloakProperty(PREFERRED_USERNAME, accessToken.getPreferredUsername()));
properties.add(new KeycloakProperty(PROFILE, accessToken.getProfile()));
properties.add(new KeycloakProperty(SUBJECT, accessToken.getSubject()));
properties.add(new KeycloakProperty(WEBSITE, accessToken.getWebsite()));
properties.add(new KeycloakProperty(ZONE_INFO, accessToken.getZoneinfo()));
properties.add(new KeycloakProperty(EMAIL_VERIFIED, String.valueOf(accessToken.getEmailVerified())));
properties.add(new KeycloakProperty(PHONENUMBER_VERIFIED, String.valueOf(accessToken.getPhoneNumberVerified())));
// populate address properties
AddressClaimSet address = accessToken.getAddress();
if (address != null) {
properties.add(new KeycloakProperty(COUNTRY, accessToken.getAddress().getCountry()));
properties.add(new KeycloakProperty(FORMATTED_ADDRESS, accessToken.getAddress().getFormattedAddress()));
properties.add(new KeycloakProperty(LOCALITY, accessToken.getAddress().getLocality()));
properties.add(new KeycloakProperty(POSTAL_CODE, accessToken.getAddress().getPostalCode()));
properties.add(new KeycloakProperty(REGION, accessToken.getAddress().getRegion()));
properties.add(new KeycloakProperty(STREET_ADDRESS, accessToken.getAddress().getStreetAddress()));
}
return properties;
}
private Collection<? extends Role> createRoles(final AccessToken accessToken) {
Set<String> roleNames = new HashSet<String>();
//Add app roles first, if any
AccessToken.Access access = accessToken.getResourceAccess(accessToken.getIssuedFor());
if(access != null && access.getRoles() != null){
roleNames.addAll(access.getRoles());
}
//Add realm roles next, if any
AccessToken.Access realmAccess = accessToken.getRealmAccess();
if(realmAccess != null && realmAccess.getRoles() != null){
roleNames.addAll(realmAccess.getRoles());
}
final List<Role> roles = new ArrayList<Role>(roleNames.size());
for (final String roleName : roleNames) {
roles.add(new RoleImpl(roleName));
}
return roles;
}
/**
* Set the {@link KeycloakSecurityContext} used to generate the logged in Keycloak {@link User}.
*
* @param keycloakSecurityContext The context used to generate the logged in Keycloak {@link User}.
*/
void setSecurityContext(final KeycloakSecurityContext keycloakSecurityContext) {
if (wrappedAuthService.isLoggedIn() && keycloakSecurityContext != null) {
throw new AlreadyLoggedInException("Logged in as " + wrappedAuthService.getUser());
}
this.keycloakSecurityContext = keycloakSecurityContext;
keycloakUser = null;
}
}