I am attempting to protect some of my Hilla views, and am running into issues. When I use rolesAllowed
in the ViewConfig
on any page, the view just disappears from the menu.
I can verify that the user has the correct role, and the security policy is being checked when accessing the view through the direct URL (correct). If the user has the role removed, they can no longer access that view (correct).
I cannot quite figure out where the disconnect happens with the menu…
In createMenuItems.js
I can see that we are creating the Signal from the value in window.Vaadin?.views
and I can also see during a breakpoint that that value in window.Vaadin?.views
is missing any of the views that have rolesAllowed
configured. I just don’t know why it’s missing from there, and cannot figure out how it is created / filled.
Any help would be greatly appreciated.
I am using Spring Security / stateless auth FYI in case that matters.
Vaadin 24.7.5
Edit: I finally found where the views are being limited. Seems like it’s an issue with falling back to Spring Security to check the roles, and it is using the auth.getAuthorities() which is empty.
I use oauth2 to ensure the user is authenticated, but my authorization is done with roles stored in my database.
Eventually the call stack gets to here:
RouteUtil.java
private static void filterClientViews(
Map<String, AvailableViewInfo> configurations,
HttpServletRequest request) {
final boolean isUserAuthenticated = request.getUserPrincipal() != null;
Set<String> clientEntries = new HashSet<>(configurations.keySet());
for (String key : clientEntries) {
if (!configurations.containsKey(key)) {
continue;
}
AvailableViewInfo viewInfo = configurations.get(key);
boolean routeValid = validateViewAccessible(viewInfo,
isUserAuthenticated, request::isUserInRole);
which then calls the below function with request::isUserInRole
private boolean isGranted(String role) {
Authentication auth = getAuthentication();
if (this.rolePrefix != null && role != null && !role.startsWith(this.rolePrefix)) {
role = this.rolePrefix + role;
}
if ((auth == null) || (auth.getPrincipal() == null)) {
return false;
}
Collection<? extends GrantedAuthority> authorities = auth.getAuthorities();
if (authorities == null) {
return false;
}
for (GrantedAuthority grantedAuthority : authorities) {
if (role.equals(grantedAuthority.getAuthority())) {
return true;
}
}
return false;
}
And here auth.getAuthorities() is a JWT token that is empty.
Any help with how to properly set the authorities would be greatly appreciated. One thing to note is my token is converted from oauth2 to jwt (due to stateless session).
The related code from my app is below for reference:
auth.ts
import { configureAuth } from '@vaadin/hilla-react-auth';
import { AuthInfoService } from './generated/endpoints';
const auth = configureAuth(AuthInfoService.getAuthInfo);
export const useAuth = auth.useAuth;
export const AuthProvider = auth.AuthProvider;
AuthInfoService.java
package ai.goacquire.frontend.security;
import ai.goacquire.shared.dto.AuthInfoDTO;
import ai.goacquire.shared.mapper.AuthInfoMapper;
import ai.goacquire.shared.repository.UserRepository;
import com.vaadin.hilla.BrowserCallable;
import jakarta.annotation.security.PermitAll;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import java.time.Instant;
import java.util.Optional;
@BrowserCallable
public class AuthInfoService {
private final UserRepository userRepository;
private final AuthInfoMapper authInfoMapper;
public AuthInfoService(UserRepository userRepository, AuthInfoMapper authInfoMapper) {
this.userRepository = userRepository;
this.authInfoMapper = authInfoMapper;
}
@PermitAll
public Optional<AuthInfoDTO> getAuthInfo() {
Object principal = SecurityContextHolder.getContext().getAuthentication().getPrincipal();
if (principal instanceof Jwt) {
String username = ((Jwt) principal).getSubject();
if (((Jwt) principal).getExpiresAt().isAfter(Instant.now())) {
AuthInfoDTO authInfo = authInfoMapper.toDto(userRepository.findByEmail(username).orElse(null));
return Optional.of(authInfo);
}
}
// Anonymous or no authentication.
return Optional.empty();
}
}
SecurityConfig.java
package ai.goacquire.frontend.config;
import ai.goacquire.frontend.security.OAuth2LoginSuccessHandler;
import com.vaadin.flow.spring.security.VaadinWebSecurity;
import com.vaadin.hilla.route.RouteUtil;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.jose.jws.JwsAlgorithms;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.crypto.spec.SecretKeySpec;
import java.util.Base64;
@EnableWebSecurity
@Configuration
@EnableMethodSecurity
public class SecurityConfig extends VaadinWebSecurity {
protected final Log logger = LogFactory.getLog(this.getClass());
private final RouteUtil routeUtil;
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
@Value("${auth.secret}")
private String authSecret;
public SecurityConfig(RouteUtil routeUtil, OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler) {
this.routeUtil = routeUtil;
this.oAuth2LoginSuccessHandler = oAuth2LoginSuccessHandler;
}
@Override
protected void configure(HttpSecurity http) throws Exception {
// First set up all the OAuth and security related paths
http.authorizeHttpRequests(registry -> registry
.requestMatchers(routeUtil::isRouteAllowed).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/microsoft")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/login/oauth2/authorization/google")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/access-denied")).permitAll()
.requestMatchers(new AntPathRequestMatcher("/error")).permitAll()
)
// Configure OAuth2 login
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.successHandler(oAuth2LoginSuccessHandler)
);
// Default config to allow Vaadin paths
super.configure(http);
http.sessionManagement(httpSecuritySessionManagementConfigurer -> httpSecuritySessionManagementConfigurer.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED));
setLoginView(http, "/login");
// Enable stateless authentication
setStatelessAuthentication(http,
new SecretKeySpec(Base64.getDecoder().decode(authSecret), JwsAlgorithms.HS256)
,"ai.goacquire.frontend"
, 43200);
}
}
And finally OAuth2LoginSuccessHandler
package ai.goacquire.frontend.security;
import ai.goacquire.shared.entity.User;
import ai.goacquire.shared.mapper.AuthInfoMapper;
import ai.goacquire.shared.service.UserLoginService;
import ai.goacquire.shared.service.UserLoginService.UserLoginException;
import jakarta.servlet.http.Cookie;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.user.OAuth2User;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler;
import org.springframework.stereotype.Component;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.*;
import java.util.stream.Collectors;
@Component
public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler {
private static final Logger logger = LoggerFactory.getLogger(OAuth2LoginSuccessHandler.class);
private final UserLoginService userLoginService;
public OAuth2LoginSuccessHandler(UserLoginService userLoginService, AuthInfoMapper authInfoMapper) {
this.userLoginService = userLoginService;
setDefaultTargetUrl("/");
}
@Override
public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response,
Authentication authentication) throws IOException, ServletException {
OAuth2User oAuth2User = (OAuth2User) authentication.getPrincipal();
// ENHANCED DEBUGGING: Log all available attributes
logger.info("OAuth authentication success. Attributes received: {}", oAuth2User.getAttributes());
try {
String provider = determineProvider(request, oAuth2User);
logger.info("Detected provider: {}", provider);
String providerId = getAttributeValue(oAuth2User, "sub", "oid");
String email = getAttributeValue(oAuth2User, "email", "preferred_username", "upn", "mail");
String firstName = getAttributeValue(oAuth2User, "given_name", "givenname", "givenName");
String lastName = getAttributeValue(oAuth2User, "family_name", "surname", "familyname", "lastname");
// ENHANCED DEBUGGING: Log extracted user information
logger.info("Extracted OAuth user info - Provider ID: {}, Email: {}, First Name: {}, Last Name: {}",
providerId, email, firstName, lastName);
validateRequiredFields(email, providerId);
logger.info("Attempting login for user email: {}, provider: {}", email, provider);
try {
User user = userLoginService.login(email, firstName, lastName, provider, providerId);
// should I do something here to update the authorities in the authentication object?
logger.info("Successful login for user: {}", email);
for (Cookie cookie : request.getCookies())
{
if (cookie.getName().equals("REDIRECT_URI")) {
byte[] decodedBytes = Base64.getDecoder().decode(cookie.getValue());
String redirectUri = new String(decodedBytes, "UTF-8");
setDefaultTargetUrl(redirectUri);
break;
}
}
super.onAuthenticationSuccess(request, response, authentication);
} catch (UserLoginException e) {
// ENHANCED DEBUGGING: Log detailed exception information
logger.warn("Login failed for user: {} - Error type: {} - Message: {}",
email, e.getErrorType(), e.getMessage());
switch (e.getErrorType()) {
case NOT_REGISTERED:
logger.info("Redirecting to access-denied/not-registered");
response.sendRedirect("/access-denied/not-registered");
break;
case INACTIVE_USER:
logger.info("Redirecting to access-denied/inactive");
response.sendRedirect("/access-denied/inactive");
break;
case SYSTEM_ERROR:
default:
logger.error("System error during login: {}", e.getMessage(), e);
logger.info("Redirecting to access-denied/default");
response.sendRedirect("/access-denied/default");
break;
}
}
} catch (IllegalStateException e) {
logger.error("Provider determination or validation failed: {}", e.getMessage(), e);
logger.info("Redirecting to login with error parameter");
response.sendRedirect("/login?error");
} catch (Exception e) {
logger.error("Unexpected error during authentication: ", e);
logger.info("Redirecting to login with error parameter");
response.sendRedirect("/login?error");
}
}
// Existing helper methods remain unchanged
private String getAttributeValue(OAuth2User oAuth2User, String... possibleKeys) {
Map<String, Object> attributes = oAuth2User.getAttributes();
for (String key : possibleKeys) {
Object value = attributes.get(key);
if (value != null) {
return value.toString();
}
}
return null;
}
private String determineProvider(HttpServletRequest request, OAuth2User oAuth2User) {
// ENHANCED DEBUGGING: Log the request URI and available attributes for provider detection
logger.info("Determining provider from request URI: {}", request.getRequestURI());
logger.info("OAuth attributes for provider detection: {}", oAuth2User.getAttributes());
// Check URL first
if (request.getRequestURI().contains("google")) {
return "google";
} else if (request.getRequestURI().contains("microsoft")) {
return "microsoft";
}
// Fallback to checking attributes
Map<String, Object> attributes = oAuth2User.getAttributes();
if (attributes.containsKey("iss")) {
String issuer = attributes.get("iss").toString();
logger.info("Found issuer attribute: {}", issuer);
if (issuer.contains("google")) {
return "google";
} else if (issuer.contains("microsoft") || issuer.contains("live.com")) {
return "microsoft";
}
}
// ENHANCED DEBUGGING: Log detailed information before throwing exception
logger.error("Unable to determine OAuth2 provider. Request URI: {}, Available attributes: {}",
request.getRequestURI(), attributes.keySet());
throw new IllegalStateException("Unable to determine OAuth2 provider");
}
private void validateRequiredFields(String email, String providerId) {
if (email == null || email.trim().isEmpty()) {
logger.error("Email is required but was not provided by OAuth2 provider");
throw new IllegalStateException("Email is required but was not provided by OAuth2 provider");
}
if (providerId == null || providerId.trim().isEmpty()) {
logger.error("Provider ID is required but was not provided by OAuth2 provider");
throw new IllegalStateException("Provider ID is required but was not provided by OAuth2 provider");
}
}
}