Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

DRAFT: Berechtigungen pflegen #39

Closed
wants to merge 14 commits into from
Closed
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,11 +152,11 @@ users.

As a user of a tenant can log in via `http://localhost:8060/`:

| username | password | role |
|---------------------|----------|------------------|
| [email protected] | secret | view_reports_all |
| [email protected] | secret | view_reports_all |
| [email protected] | secret | |
| username | password | role |
|---------------------|----------|------------------------------------|
| [email protected] | secret | view_reports_all, edit_authorities |
| [email protected] | secret | view_reports_all, edit_authorities |
| [email protected] | secret | |


### git hooks (optional)
Expand Down
10 changes: 9 additions & 1 deletion docker/keycloak/keycloak-export/zeiterfassung-realm-realm.json
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,14 @@
"clientRole" : false,
"containerId" : "zeiterfassung-realm",
"attributes" : { }
}, {
"id" : "2c214c0a-fcc5-4a10-b989-5cc2043fcb8c",
"name" : "ZEITERFASSUNG_EDIT_AUTHORITIES",
"description" : "",
"composite" : false,
"clientRole" : false,
"containerId" : "zeiterfassung-realm",
"attributes" : { }
} ],
"client" : {
"realm-management" : [ {
Expand Down Expand Up @@ -365,7 +373,7 @@
"name" : "ZEITERFASSUNG_PRIVILEGED",
"path" : "/ZEITERFASSUNG_PRIVILEGED",
"attributes" : { },
"realmRoles" : [ "ZEITERFASSUNG_VIEW_REPORT_ALL" ],
"realmRoles" : [ "ZEITERFASSUNG_VIEW_REPORT_ALL", "ZEITERFASSUNG_EDIT_AUTHORITIES" ],
"clientRoles" : { },
"subGroups" : [ ]
} ],
Expand Down
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
"eslint-plugin-prettier": "4.2.1",
"eslint-plugin-svelte3": "4.0.0",
"eslint-plugin-unicorn": "44.0.2",
"fast-glob": "3.2.12",
"lint-staged": "13.0.3",
"postcss": "8.4.19",
"postcss-cli": "10.0.0",
Expand Down
6 changes: 6 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@
<artifactId>thymeleaf-extras-springsecurity5</artifactId>
</dependency>

<!-- SESSION -->
<dependency>
<groupId>org.springframework.session</groupId>
<artifactId>spring-session-jdbc</artifactId>
</dependency>

<dependency>
<groupId>org.threeten</groupId>
<artifactId>threeten-extra</artifactId>
Expand Down
17 changes: 12 additions & 5 deletions rollup.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import commonjs from "@rollup/plugin-commonjs";
import esbuild from "rollup-plugin-esbuild";
import svelte from "rollup-plugin-svelte";
import sveltePreprocess from "svelte-preprocess";
import glob from "fast-glob";

const paths = {
src: "src/main/javascript",
Expand All @@ -14,11 +15,7 @@ const paths = {
export default {
input: {
"custom-elements-polyfill": `@ungap/custom-elements`,
"date-fns-localized": `${paths.src}/bundles/date-fns-localized.ts`,
reports: `${paths.src}/bundles/reports.ts`,
"time-entries": `${paths.src}/bundles/time-entries.ts`,
turbo: `${paths.src}/bundles/turbo.ts`,
"user-common": `${paths.src}/bundles/user-common.ts`,
...inputFiles(),
},
output: {
dir: paths.dist,
Expand Down Expand Up @@ -54,3 +51,13 @@ export default {
}),
],
};

function inputFiles() {
const files = glob.sync(`${paths.src}/bundles/*.ts`);
return Object.fromEntries(files.map((file) => [entryName(file), file]));
}

function entryName(file) {
const filename = file.slice(Math.max(0, file.lastIndexOf("/") + 1));
return filename.slice(0, Math.max(0, filename.lastIndexOf(".")));
}
12 changes: 12 additions & 0 deletions src/main/css/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -522,6 +522,18 @@
top: 0;
left: 0;
}

.authorities {
}

.authorities__item {
@apply transition-colors;
@apply hover:bg-blue-50;
}

.authorities__item:has(input[type="checkbox"]:checked) {
@apply bg-blue-50;
}
}

@layer utilities {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package de.focusshift.zeiterfassung.security;

import de.focusshift.zeiterfassung.user.CurrentUserProvider;
import de.focusshift.zeiterfassung.usermanagement.User;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ModelAttribute;

import javax.servlet.http.HttpServletRequest;

import static de.focusshift.zeiterfassung.security.SecurityRoles.ZEITERFASSUNG_EDIT_AUTHORITIES;
import static de.focusshift.zeiterfassung.security.SecurityRoles.ZEITERFASSUNG_VIEW_REPORT_ALL;

@ControllerAdvice(basePackages = {"de.focusshift.zeiterfassung"})
public class AuthorityControllerAdvice {

private final CurrentUserProvider currentUserProvider;

public AuthorityControllerAdvice(CurrentUserProvider currentUserProvider) {
this.currentUserProvider = currentUserProvider;
}

@ModelAttribute
void authorities(Model model, HttpServletRequest request) {

final User currentUser = currentUserProvider.getCurrentUser();

model.addAttribute("allowed_viewReportsAll", currentUser.hasAuthority(ZEITERFASSUNG_VIEW_REPORT_ALL));
model.addAttribute("allowed_editAuthorities", currentUser.hasAuthority(ZEITERFASSUNG_EDIT_AUTHORITIES));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package de.focusshift.zeiterfassung.security;

import de.focusshift.zeiterfassung.user.CurrentUserProvider;
import de.focusshift.zeiterfassung.usermanagement.User;
import org.slf4j.Logger;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
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.client.authentication.OAuth2AuthenticationToken;
import org.springframework.web.filter.OncePerRequestFilter;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import javax.validation.constraints.NotNull;
import java.io.IOException;
import java.util.List;
import java.util.stream.Stream;

import static de.focusshift.zeiterfassung.security.SessionService.RELOAD_AUTHORITIES;
import static java.lang.Boolean.TRUE;
import static java.lang.invoke.MethodHandles.lookup;
import static java.util.function.Predicate.not;
import static org.slf4j.LoggerFactory.getLogger;

class ReloadAuthenticationAuthoritiesFilter extends OncePerRequestFilter {

private static final Logger LOG = getLogger(lookup().lookupClass());

private final CurrentUserProvider currentUserProvider;
private final SessionService sessionService;

ReloadAuthenticationAuthoritiesFilter(CurrentUserProvider currentUserProvider, SessionService sessionService) {
this.currentUserProvider = currentUserProvider;
this.sessionService = sessionService;
}

@Override
protected boolean shouldNotFilter(HttpServletRequest request) {
final HttpSession session = request.getSession();
if (session == null) {
return true;
}

final Boolean reload = (Boolean) session.getAttribute(RELOAD_AUTHORITIES);
return !TRUE.equals(reload);
}

@Override
public void doFilterInternal(@NotNull HttpServletRequest request, @NotNull HttpServletResponse response, @NotNull FilterChain chain) throws ServletException, IOException {
final HttpSession session = request.getSession();
sessionService.unmarkSessionToReloadAuthorities(session.getId());

final Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
final User user = currentUserProvider.getCurrentUser();
final List<GrantedAuthority> updatedAuthorities = getUpdatedAuthorities(authentication, user);

try {
final Authentication updatedAuthentication = getUpdatedAuthentication(updatedAuthorities, authentication);
SecurityContextHolder.getContext().setAuthentication(updatedAuthentication);
LOG.info("Updated authorities of person with the id {} from {} to {}", user.id(), authentication.getAuthorities(), updatedAuthorities);
} catch (ReloadAuthenticationException e) {
LOG.error(e.getMessage());
}

chain.doFilter(request, response);
}

private List<GrantedAuthority> getUpdatedAuthorities(Authentication authentication, User user) {

final Stream<? extends GrantedAuthority> otherAuthorities = authentication.getAuthorities()
.stream()
.filter(not(grantedAuthority -> grantedAuthority.getAuthority().startsWith("ROLE_ZEITERFASSUNG")));

final Stream<SimpleGrantedAuthority> zeiterfassungAuthorities = user.authorities().stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.name()));

return Stream.concat(otherAuthorities, zeiterfassungAuthorities).toList();
}

private Authentication getUpdatedAuthentication(List<GrantedAuthority> updatedAuthorities, Authentication authentication) throws ReloadAuthenticationException {
final Authentication updatedAuthentication;
if (authentication instanceof OAuth2AuthenticationToken) {
final OAuth2AuthenticationToken oAuth2Auth = (OAuth2AuthenticationToken) authentication;
updatedAuthentication = new OAuth2AuthenticationToken(oAuth2Auth.getPrincipal(), updatedAuthorities, oAuth2Auth.getAuthorizedClientRegistrationId());
} else if (authentication instanceof UsernamePasswordAuthenticationToken) {
updatedAuthentication = new UsernamePasswordAuthenticationToken(authentication.getPrincipal(), authentication.getCredentials(), updatedAuthorities);
} else {
throw new ReloadAuthenticationException("Could not update authentication with updated authorities, because of type mismatch");
}

return updatedAuthentication;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package de.focusshift.zeiterfassung.security;

public class ReloadAuthenticationException extends Exception {
ReloadAuthenticationException(String message) {
super(message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,8 @@ public enum SecurityRoles {

ZEITERFASSUNG_OPERATOR, // used by fss employees only!
ZEITERFASSUNG_USER,
ZEITERFASSUNG_VIEW_REPORT_ALL;
ZEITERFASSUNG_VIEW_REPORT_ALL,
ZEITERFASSUNG_EDIT_AUTHORITIES;

private GrantedAuthority authority;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package de.focusshift.zeiterfassung.security;

public class SecurityRules {

public static final String ALLOW_EDIT_AUTHORITIES = "hasAuthority('ROLE_ZEITERFASSUNG_EDIT_AUTHORITIES')";

private SecurityRules() {}
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package de.focusshift.zeiterfassung.security;

import de.focusshift.zeiterfassung.user.CurrentUserProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.actuate.autoconfigure.security.servlet.EndpointRequest;
import org.springframework.boot.actuate.health.HealthEndpoint;
Expand All @@ -15,6 +16,7 @@
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;

@Configuration
Expand All @@ -25,16 +27,21 @@ class SecurityWebConfiguration {
private final AuthenticationSuccessHandler authenticationSuccessHandler;
private final OidcClientInitiatedLogoutSuccessHandler oidcClientInitiatedLogoutSuccessHandler;
private final OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService;
private final CurrentUserProvider currentUserProvider;
private final SessionService sessionService;

@Autowired
SecurityWebConfiguration(AuthenticationEntryPoint authenticationEntryPoint,
AuthenticationSuccessHandler authenticationSuccessHandler,
OidcClientInitiatedLogoutSuccessHandler oidcClientInitiatedLogoutSuccessHandler,
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService) {
OAuth2UserService<OidcUserRequest, OidcUser> oidcUserService,
CurrentUserProvider currentUserProvider, SessionService sessionService) {
this.authenticationEntryPoint = authenticationEntryPoint;
this.authenticationSuccessHandler = authenticationSuccessHandler;
this.oidcClientInitiatedLogoutSuccessHandler = oidcClientInitiatedLogoutSuccessHandler;
this.oidcUserService = oidcUserService;
this.currentUserProvider = currentUserProvider;
this.sessionService = sessionService;
}

@Bean
Expand Down Expand Up @@ -66,6 +73,9 @@ SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http.logout()
.logoutSuccessHandler(oidcClientInitiatedLogoutSuccessHandler);

http
.addFilterAfter(new ReloadAuthenticationAuthoritiesFilter(currentUserProvider, sessionService), BasicAuthenticationFilter.class);

//@formatter:on
return http.build();
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
package de.focusshift.zeiterfassung.security;


public interface SessionService {

String RELOAD_AUTHORITIES = "reloadAuthorities";

/**
* Mark the session of the given username to reload the authorities on the next page request
*
* @param username to mark to reload authorities
*/
void markSessionToReloadAuthorities(String username);

/**
* Unmark the session to not reload the authorities.
*
* @param sessionId to unmark the session
*/
void unmarkSessionToReloadAuthorities(String sessionId);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package de.focusshift.zeiterfassung.security;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.session.FindByIndexNameSessionRepository;
import org.springframework.session.Session;
import org.springframework.stereotype.Service;

import java.util.Map;

@Service
public class SessionServiceImpl<S extends Session> implements SessionService {

private final FindByIndexNameSessionRepository<S> sessionRepository;

@Autowired
SessionServiceImpl(FindByIndexNameSessionRepository<S> sessionRepository) {
this.sessionRepository = sessionRepository;
}

@Override
public void markSessionToReloadAuthorities(String username) {
final Map<String, S> map = sessionRepository.findByPrincipalName(username);
for (final S session : map.values()) {
session.setAttribute(RELOAD_AUTHORITIES, true);
sessionRepository.save(session);
}
}

@Override
public void unmarkSessionToReloadAuthorities(String sessionId) {
final S session = sessionRepository.findById(sessionId);
session.removeAttribute(RELOAD_AUTHORITIES);
sessionRepository.save(session);
}
}
Loading