diff --git a/pom.xml b/pom.xml index 16b6775..8d09937 100644 --- a/pom.xml +++ b/pom.xml @@ -76,6 +76,12 @@ org.springframework.security spring-security-config + + + org.ilay + ilay + 3.0-Final + diff --git a/src/main/java/org/vaadin/paul/spring/MainView.java b/src/main/java/org/vaadin/paul/spring/MainView.java index e514b43..aeac080 100644 --- a/src/main/java/org/vaadin/paul/spring/MainView.java +++ b/src/main/java/org/vaadin/paul/spring/MainView.java @@ -2,20 +2,25 @@ import com.vaadin.flow.component.button.Button; import com.vaadin.flow.component.notification.Notification; -import org.springframework.beans.factory.annotation.Autowired; - import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.dom.Element; +import com.vaadin.flow.dom.ElementFactory; import com.vaadin.flow.router.Route; import com.vaadin.flow.server.PWA; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.annotation.Secured; @Route @PWA(name = "Project Base for Vaadin Flow with Spring", shortName = "Project Base") public class MainView extends VerticalLayout { - public MainView(@Autowired MessageBean bean) { Button button = new Button("Click me", e -> Notification.show(bean.getMessage())); add(button); + + // simple link to the logout endpoint provided by Spring Security + Element logoutLink = ElementFactory.createAnchor("logout", "Logout"); + getElement().appendChild(logoutLink); } } diff --git a/src/main/java/org/vaadin/paul/spring/MessageBean.java b/src/main/java/org/vaadin/paul/spring/MessageBean.java index 3fd9b32..9332c88 100644 --- a/src/main/java/org/vaadin/paul/spring/MessageBean.java +++ b/src/main/java/org/vaadin/paul/spring/MessageBean.java @@ -5,7 +5,6 @@ @Service public class MessageBean { - public String getMessage() { return "Button was clicked at " + LocalTime.now(); } diff --git a/src/main/java/org/vaadin/paul/spring/app/security/ConfigureUIServiceInitListener.java b/src/main/java/org/vaadin/paul/spring/app/security/ConfigureUIServiceInitListener.java deleted file mode 100644 index ae1f1a0..0000000 --- a/src/main/java/org/vaadin/paul/spring/app/security/ConfigureUIServiceInitListener.java +++ /dev/null @@ -1,33 +0,0 @@ -package org.vaadin.paul.spring.app.security; - -import com.vaadin.flow.component.UI; -import com.vaadin.flow.router.BeforeEnterEvent; -import com.vaadin.flow.server.ServiceInitEvent; -import com.vaadin.flow.server.VaadinServiceInitListener; -import org.springframework.stereotype.Component; -import org.vaadin.paul.spring.ui.views.LoginView; - -@Component -public class ConfigureUIServiceInitListener implements VaadinServiceInitListener { - - @Override - public void serviceInit(ServiceInitEvent event) { - event.getSource().addUIInitListener(uiEvent -> { - final UI ui = uiEvent.getUI(); - ui.addBeforeEnterListener(this::beforeEnter); - }); - } - - /** - * Reroutes the user if (s)he is not authorized to access the view. - * - * @param event - * before navigation event with event details - */ - private void beforeEnter(BeforeEnterEvent event) { - if (!LoginView.class.equals(event.getNavigationTarget()) - && !SecurityUtils.isUserLoggedIn()) { - event.rerouteTo(LoginView.class); - } - } -} \ No newline at end of file diff --git a/src/main/java/org/vaadin/paul/spring/app/security/CustomRequestCache.java b/src/main/java/org/vaadin/paul/spring/app/security/CustomRequestCache.java index 31576f9..a1483c1 100644 --- a/src/main/java/org/vaadin/paul/spring/app/security/CustomRequestCache.java +++ b/src/main/java/org/vaadin/paul/spring/app/security/CustomRequestCache.java @@ -1,6 +1,11 @@ package org.vaadin.paul.spring.app.security; +import com.vaadin.flow.server.VaadinServletRequest; +import com.vaadin.flow.server.VaadinServletResponse; +import org.springframework.security.web.savedrequest.DefaultSavedRequest; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; +import org.springframework.security.web.savedrequest.SavedRequest; +import org.vaadin.paul.spring.ui.views.LoginView; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; @@ -8,7 +13,7 @@ /** * HttpSessionRequestCache that avoids saving internal framework requests. */ -class CustomRequestCache extends HttpSessionRequestCache { +public class CustomRequestCache extends HttpSessionRequestCache { /** * {@inheritDoc} * @@ -24,4 +29,22 @@ public void saveRequest(HttpServletRequest request, HttpServletResponse response } } + /** + * Unfortunately, it's not that easy to resolve the redirect URL from the saved request. But with some + * casting (we always use {@link DefaultSavedRequest}) and mangling we are able to get the request URI. + */ + public String resolveRedirectUrl() { + SavedRequest savedRequest = getRequest(VaadinServletRequest.getCurrent().getHttpServletRequest(), VaadinServletResponse.getCurrent().getHttpServletResponse()); + if(savedRequest instanceof DefaultSavedRequest) { + final String requestURI = ((DefaultSavedRequest) savedRequest).getRequestURI(); + // check for valid URI and prevent redirecting to the login view + if (requestURI != null && !requestURI.isEmpty() && !requestURI.contains(LoginView.ROUTE)) { + return requestURI.startsWith("/") ? requestURI.substring(1) : requestURI; + } + } + + // if everything fails, redirect to the main view + return ""; + } + } \ No newline at end of file diff --git a/src/main/java/org/vaadin/paul/spring/app/security/NameBasedEvaluator.java b/src/main/java/org/vaadin/paul/spring/app/security/NameBasedEvaluator.java new file mode 100644 index 0000000..282669c --- /dev/null +++ b/src/main/java/org/vaadin/paul/spring/app/security/NameBasedEvaluator.java @@ -0,0 +1,24 @@ +package org.vaadin.paul.spring.app.security; + +import com.vaadin.flow.router.Location; +import com.vaadin.flow.router.NotFoundException; +import org.ilay.Access; +import org.ilay.AccessEvaluator; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; + +public class NameBasedEvaluator implements AccessEvaluator { + @Override + public Access evaluate(Location location, Class navigationTarget, SecuredForPaul annotation) { + if(SecurityUtils.isUserLoggedIn()) { + final Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + final UserDetails principal = (UserDetails)authentication.getPrincipal(); + if(principal.getUsername().equals("paul")) { + return Access.granted(); + } + } + + return Access.restricted(NotFoundException.class); + } +} \ No newline at end of file diff --git a/src/main/java/org/vaadin/paul/spring/app/security/RoleBasedEvaluator.java b/src/main/java/org/vaadin/paul/spring/app/security/RoleBasedEvaluator.java new file mode 100644 index 0000000..f58787a --- /dev/null +++ b/src/main/java/org/vaadin/paul/spring/app/security/RoleBasedEvaluator.java @@ -0,0 +1,22 @@ +package org.vaadin.paul.spring.app.security; + +import com.vaadin.flow.router.Location; +import com.vaadin.flow.router.NotFoundException; +import org.ilay.Access; +import org.ilay.AccessEvaluator; +import org.vaadin.paul.spring.ui.views.LoginView; + +public class RoleBasedEvaluator implements AccessEvaluator { + @Override + public Access evaluate(Location location, Class navigationTarget, SecuredByRole annotation) { + if(!SecurityUtils.isAccessGranted(navigationTarget, annotation)) { + if(SecurityUtils.isUserLoggedIn()) { + return Access.restricted(NotFoundException.class); + } else { + return Access.restricted(LoginView.ROUTE); + } + } + + return Access.granted(); + } +} \ No newline at end of file diff --git a/src/main/java/org/vaadin/paul/spring/app/security/SecuredByRole.java b/src/main/java/org/vaadin/paul/spring/app/security/SecuredByRole.java new file mode 100644 index 0000000..ebe5557 --- /dev/null +++ b/src/main/java/org/vaadin/paul/spring/app/security/SecuredByRole.java @@ -0,0 +1,12 @@ +package org.vaadin.paul.spring.app.security; + +import org.ilay.NavigationAnnotation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@NavigationAnnotation(RoleBasedEvaluator.class) +@Retention(RetentionPolicy.RUNTIME) +public @interface SecuredByRole { + String value() default ""; +} \ No newline at end of file diff --git a/src/main/java/org/vaadin/paul/spring/app/security/SecuredForPaul.java b/src/main/java/org/vaadin/paul/spring/app/security/SecuredForPaul.java new file mode 100644 index 0000000..ad93c40 --- /dev/null +++ b/src/main/java/org/vaadin/paul/spring/app/security/SecuredForPaul.java @@ -0,0 +1,11 @@ +package org.vaadin.paul.spring.app.security; + +import org.ilay.NavigationAnnotation; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@NavigationAnnotation(NameBasedEvaluator.class) +@Retention(RetentionPolicy.RUNTIME) +public @interface SecuredForPaul { +} \ No newline at end of file diff --git a/src/main/java/org/vaadin/paul/spring/app/security/SecurityConfiguration.java b/src/main/java/org/vaadin/paul/spring/app/security/SecurityConfiguration.java index ba60333..ee7bc80 100644 --- a/src/main/java/org/vaadin/paul/spring/app/security/SecurityConfiguration.java +++ b/src/main/java/org/vaadin/paul/spring/app/security/SecurityConfiguration.java @@ -2,6 +2,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.builders.WebSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -10,6 +11,7 @@ import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.vaadin.paul.spring.ui.views.LoginView; /** * Configures spring security, doing the following: @@ -22,10 +24,18 @@ @Configuration public class SecurityConfiguration extends WebSecurityConfigurerAdapter { - private static final String LOGIN_PROCESSING_URL = "/login"; - private static final String LOGIN_FAILURE_URL = "/login"; - private static final String LOGIN_URL = "/login"; - private static final String LOGOUT_SUCCESS_URL = "/login"; + private static final String LOGOUT_SUCCESS_URL = "/"; + + @Bean + @Override + public AuthenticationManager authenticationManagerBean() throws Exception { + return super.authenticationManagerBean(); + } + + @Bean + public CustomRequestCache requestCache() { + return new CustomRequestCache(); + } /** * Require login to access internal pages and configure login form. @@ -37,7 +47,7 @@ protected void configure(HttpSecurity http) throws Exception { // Register our CustomRequestCache, that saves unauthorized access attempts, so // the user is redirected after login. - .requestCache().requestCache(new CustomRequestCache()) + .requestCache().requestCache(requestCache()) // Restrict access to our application. .and().authorizeRequests() @@ -49,8 +59,7 @@ protected void configure(HttpSecurity http) throws Exception { .anyRequest().authenticated() // Configure the login page. - .and().formLogin().loginPage(LOGIN_URL).permitAll().loginProcessingUrl(LOGIN_PROCESSING_URL) - .failureUrl(LOGIN_FAILURE_URL) + .and().formLogin().loginPage("/" + LoginView.ROUTE).permitAll() // Configure logout .and().logout().logoutSuccessUrl(LOGOUT_SUCCESS_URL); @@ -59,13 +68,28 @@ protected void configure(HttpSecurity http) throws Exception { @Bean @Override public UserDetailsService userDetailsService() { - UserDetails user = + // typical logged in user with some privileges + UserDetails normalUser = User.withUsername("user") .password("{noop}password") - .roles("USER") + .roles("User") + .build(); + + // admin user with all privileges + UserDetails adminUser = + User.withUsername("admin") + .password("{noop}password") + .roles("User", "Admin") + .build(); + + // admin user with all privileges + UserDetails paulUser = + User.withUsername("paul") + .password("{noop}password") + .roles("User") .build(); - return new InMemoryUserDetailsManager(user); + return new InMemoryUserDetailsManager(normalUser, adminUser, paulUser); } /** diff --git a/src/main/java/org/vaadin/paul/spring/app/security/SecurityUtils.java b/src/main/java/org/vaadin/paul/spring/app/security/SecurityUtils.java index a91ca00..d7810bc 100644 --- a/src/main/java/org/vaadin/paul/spring/app/security/SecurityUtils.java +++ b/src/main/java/org/vaadin/paul/spring/app/security/SecurityUtils.java @@ -4,9 +4,12 @@ import com.vaadin.flow.shared.ApplicationConstants; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import javax.servlet.http.HttpServletRequest; +import java.util.Arrays; +import java.util.List; import java.util.stream.Stream; /** @@ -39,10 +42,26 @@ static boolean isFrameworkInternalRequest(HttpServletRequest request) { * Tests if some user is authenticated. As Spring Security always will create an {@link AnonymousAuthenticationToken} * we have to ignore those tokens explicitly. */ - static boolean isUserLoggedIn() { + public static boolean isUserLoggedIn() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); return authentication != null && !(authentication instanceof AnonymousAuthenticationToken) && authentication.isAuthenticated(); } + + /** + * Checks if access is granted for the current user for the given secured view, + * defined by the view class. + * + * @param securedClass View class + * @param annotation + * @return true if access is granted, false otherwise. + */ + public static boolean isAccessGranted(Class securedClass, SecuredByRole annotation) { + // lookup needed role in user roles + final List allowedRoles = Arrays.asList(annotation.value()); + final Authentication userAuthentication = SecurityContextHolder.getContext().getAuthentication(); + return userAuthentication.getAuthorities().stream().map(GrantedAuthority::getAuthority) + .anyMatch(allowedRoles::contains); + } } diff --git a/src/main/java/org/vaadin/paul/spring/ui/views/AdminView.java b/src/main/java/org/vaadin/paul/spring/ui/views/AdminView.java new file mode 100644 index 0000000..71e12b5 --- /dev/null +++ b/src/main/java/org/vaadin/paul/spring/ui/views/AdminView.java @@ -0,0 +1,18 @@ +package org.vaadin.paul.spring.ui.views; + +import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import org.springframework.beans.factory.annotation.Autowired; +import org.vaadin.paul.spring.app.security.SecuredByRole; + +@Route("admin") +@SecuredByRole("ROLE_Admin") +public class AdminView extends VerticalLayout { + @Autowired + public AdminView() { + Label label = new Label("Looks like you are admin!"); + add(label); + } + +} diff --git a/src/main/java/org/vaadin/paul/spring/ui/views/LoginView.java b/src/main/java/org/vaadin/paul/spring/ui/views/LoginView.java index b3c71a9..b7492cb 100644 --- a/src/main/java/org/vaadin/paul/spring/ui/views/LoginView.java +++ b/src/main/java/org/vaadin/paul/spring/ui/views/LoginView.java @@ -1,31 +1,62 @@ package org.vaadin.paul.spring.ui.views; -import com.vaadin.flow.component.AttachEvent; -import com.vaadin.flow.component.Component; import com.vaadin.flow.component.Tag; -import com.vaadin.flow.component.dependency.HtmlImport; -import com.vaadin.flow.component.page.Viewport; -import com.vaadin.flow.component.polymertemplate.PolymerTemplate; -import com.vaadin.flow.router.AfterNavigationEvent; -import com.vaadin.flow.router.AfterNavigationObserver; +import com.vaadin.flow.component.UI; +import com.vaadin.flow.component.login.LoginOverlay; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; import com.vaadin.flow.router.PageTitle; import com.vaadin.flow.router.Route; -import com.vaadin.flow.server.InitialPageSettings; -import com.vaadin.flow.server.PageConfigurator; -import com.vaadin.flow.templatemodel.TemplateModel; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.vaadin.paul.spring.app.security.CustomRequestCache; @Tag("sa-login-view") @Route(value = LoginView.ROUTE) @PageTitle("Login") -public class LoginView extends Component { +public class LoginView extends VerticalLayout { public static final String ROUTE = "login"; - public interface Model extends TemplateModel { - void setError(boolean error); - } + private LoginOverlay login = new LoginOverlay(); + + @Autowired + public LoginView(AuthenticationManager authenticationManager, + CustomRequestCache requestCache) { + // configures login dialog and adds it to the main view + login.setOpened(true); + login.setTitle("Spring Secured Vaadin"); + login.setDescription("Login Overlay Example"); + + add(login); + + login.addLoginListener(e -> { + try { + // try to authenticate with given credentials, should always return !null or throw an {@link AuthenticationException} + final Authentication authentication = authenticationManager + .authenticate(new UsernamePasswordAuthenticationToken(e.getUsername(), e.getPassword())); + + // if authentication was successful we will update the security context and redirect to the page requested first + if(authentication != null ) { + login.close(); + SecurityContextHolder.getContext().setAuthentication(authentication); - @Override - protected void onAttach(AttachEvent attachEvent) { - getElement().setText("Do you wonder why you do not see any login UI? Well, this is the master branch only containing the security setup. For different UI implementations check the other branches :)"); + //Access to view by role + if (authentication.getAuthorities().stream().map(GrantedAuthority::getAuthority).anyMatch(role -> role.equals("ROLE_Admin"))) { + UI.getCurrent().navigate(AdminView.class); + } else { + UI.getCurrent().navigate(requestCache.resolveRedirectUrl()); + } + } + } catch (AuthenticationException ex) { + // show default error message + // Note: You should not expose any detailed information here like "username is known but password is wrong" + // as it weakens security. + login.setError(true); + } + }); } } diff --git a/src/main/java/org/vaadin/paul/spring/ui/views/PaulView.java b/src/main/java/org/vaadin/paul/spring/ui/views/PaulView.java new file mode 100644 index 0000000..2482ccf --- /dev/null +++ b/src/main/java/org/vaadin/paul/spring/ui/views/PaulView.java @@ -0,0 +1,17 @@ +package org.vaadin.paul.spring.ui.views; + +import com.vaadin.flow.component.html.Label; +import com.vaadin.flow.component.orderedlayout.VerticalLayout; +import com.vaadin.flow.router.Route; +import org.springframework.beans.factory.annotation.Autowired; +import org.vaadin.paul.spring.app.security.SecuredForPaul; + +@Route("backdoor") +@SecuredForPaul +public class PaulView extends VerticalLayout { + @Autowired + public PaulView() { + Label label = new Label("Hello Paul!"); + add(label); + } +}