diff --git a/.gitignore b/.gitignore index 837edb7..a489095 100644 --- a/.gitignore +++ b/.gitignore @@ -123,4 +123,6 @@ gradle-app.setting !/config/README.md !/config/*.example +application-local.yml + diff --git a/README.md b/README.md index b0a2367..34a713e 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,8 @@ CSRF is enabled by default and the example jQuery AJAX calls pass the CSRF token An audit event and listener are implmented to allow for recording security events, or any type of event you like, and logging them to a seperate file. You can easily replace the logging listener with your own and store audit events in a database, publish them to a REST API, or anything else. +There is Role and Privilege setup service, which allows you to easily define Roles, associated Privileges, and Role inheritance hierachy in your application.yml. Check out the application.yml for the basic OOTB configuration, and look at the RolePrivilegeSetupService component. You can still create and leverage roles and privileges programatically, but this makes it easy to define and see the associations. + ## How To Get Started diff --git a/build.gradle b/build.gradle index 512532c..a15a195 100644 --- a/build.gradle +++ b/build.gradle @@ -2,7 +2,6 @@ plugins { id 'org.springframework.boot' version '2.7.2' id 'io.spring.dependency-management' version '1.0.11.RELEASE' id 'java' - id 'eclipse' } group = 'com.digitalsanctuary.spring' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 8049c68..ae04661 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,5 +1,5 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java index 19e30ed..fde9b64 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Privilege.java @@ -1,13 +1,11 @@ package com.digitalsanctuary.spring.user.persistence.model; import java.util.Collection; - import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.ManyToMany; - import lombok.Data; /** @@ -17,6 +15,7 @@ @Entity public class Privilege { + /** The id. */ @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -25,7 +24,25 @@ public class Privilege { /** The name. */ private String name; - /** The roles. */ + /** The description of the role. */ + private String description; + + /** The roles which have this privilege. */ @ManyToMany(mappedBy = "privileges") private Collection roles; + + public Privilege() { + super(); + } + + public Privilege(final String name) { + super(); + this.name = name; + } + + public Privilege(final String name, final String description) { + super(); + this.name = name; + this.description = description; + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java index e2546e4..8f58aa9 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java +++ b/src/main/java/com/digitalsanctuary/spring/user/persistence/model/Role.java @@ -18,7 +18,6 @@ @Data @Entity public class Role { - /** The id. */ @Id @GeneratedValue(strategy = GenerationType.AUTO) @@ -30,9 +29,28 @@ public class Role { /** The privileges. */ @ManyToMany - @JoinTable(name = "roles_privileges", joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id")) + @JoinTable(name = "roles_privileges", joinColumns = @JoinColumn(name = "role_id", referencedColumnName = "id"), + inverseJoinColumns = @JoinColumn(name = "privilege_id", referencedColumnName = "id")) private Collection privileges; /** The name. */ private String name; + + private String description; + + + public Role() { + super(); + } + + public Role(final String name) { + super(); + this.name = name; + } + + public Role(final String name, final String description) { + super(); + this.name = name; + this.description = description; + } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/RolePrivilegeSetupService.java b/src/main/java/com/digitalsanctuary/spring/user/service/RolePrivilegeSetupService.java new file mode 100644 index 0000000..8df1260 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/service/RolePrivilegeSetupService.java @@ -0,0 +1,81 @@ +package com.digitalsanctuary.spring.user.service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import javax.transaction.Transactional; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.ApplicationListener; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.stereotype.Component; +import com.digitalsanctuary.spring.user.persistence.model.Privilege; +import com.digitalsanctuary.spring.user.persistence.model.Role; +import com.digitalsanctuary.spring.user.persistence.repository.PrivilegeRepository; +import com.digitalsanctuary.spring.user.persistence.repository.RoleRepository; +import com.digitalsanctuary.spring.user.util.RolesAndPrivilegesConfig; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Data +@Component +public class RolePrivilegeSetupService implements ApplicationListener { + private boolean alreadySetup = false; + + @Autowired + private RolesAndPrivilegesConfig rolesAndPrivilegesConfig; + + + @Autowired + private RoleRepository roleRepository; + + @Autowired + private PrivilegeRepository privilegeRepository; + + + @Override + @Transactional + public void onApplicationEvent(final ContextRefreshedEvent event) { + if (alreadySetup) { + return; + } + + log.debug("rolesAndPrivilegesConfig: " + rolesAndPrivilegesConfig); + + for (Map.Entry> entry : rolesAndPrivilegesConfig.getRolesAndPrivileges().entrySet()) { + final String roleName = entry.getKey(); + final List privileges = entry.getValue(); + if (roleName != null && privileges != null) { + final List privilegesList = new ArrayList(); + for (String privilegeName : privileges) { + privilegesList.add(createPrivilegeIfNotFound(privilegeName)); + } + createRoleIfNotFound(roleName, privilegesList); + } + + } + alreadySetup = true; + } + + @Transactional + Privilege createPrivilegeIfNotFound(final String name) { + Privilege privilege = privilegeRepository.findByName(name); + if (privilege == null) { + privilege = new Privilege(name); + privilege = privilegeRepository.save(privilege); + } + return privilege; + } + + @Transactional + Role createRoleIfNotFound(final String name, final Collection privileges) { + Role role = roleRepository.findByName(name); + if (role == null) { + role = new Role(name); + } + role.setPrivileges(privileges); + role = roleRepository.save(role); + return role; + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/RolesAndPrivilegesConfig.java b/src/main/java/com/digitalsanctuary/spring/user/util/RolesAndPrivilegesConfig.java new file mode 100644 index 0000000..d9024a8 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/util/RolesAndPrivilegesConfig.java @@ -0,0 +1,38 @@ +package com.digitalsanctuary.spring.user.util; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; +import lombok.Data; + +@Data +@Configuration +@ConfigurationProperties(prefix = "user") +public class RolesAndPrivilegesConfig { + private Map> rolesAndPrivileges = new HashMap>(); + + private List roleHierarchy = new ArrayList(); + + public String getRoleHierarchyString() { + StringBuffer roleHierarchyStringBuf = new StringBuffer(); + if (roleHierarchy != null && !roleHierarchy.isEmpty()) { + + for (String roleRelationship : roleHierarchy) { + roleHierarchyStringBuf.append(roleRelationship); + roleHierarchyStringBuf.append("\n"); + } + } + // If we have built a list of hierarchy relationships, then return it. + if (roleHierarchyStringBuf.length() > 0) { + return roleHierarchyStringBuf.toString(); + } else { + // otherwise, return null. + return null; + } + + } + +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/util/WebSecurityConfig.java index a70936d..e5c2d75 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/util/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/util/WebSecurityConfig.java @@ -2,13 +2,15 @@ import java.util.ArrayList; import java.util.Arrays; - import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.access.expression.SecurityExpressionHandler; +import org.springframework.security.access.hierarchicalroles.RoleHierarchy; +import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.dao.DaoAuthenticationProvider; import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder; @@ -21,13 +23,16 @@ import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; - +import org.springframework.security.web.FilterInvocation; +import org.springframework.security.web.access.expression.DefaultWebSecurityExpressionHandler; +import org.springframework.security.web.session.HttpSessionEventPublisher; import com.digitalsanctuary.spring.user.service.LoginSuccessService; import com.digitalsanctuary.spring.user.service.LogoutSuccessService; - import lombok.Data; import lombok.EqualsAndHashCode; +import lombok.extern.slf4j.Slf4j; +@Slf4j @Data @EqualsAndHashCode(callSuper = false) @Configuration @@ -97,6 +102,9 @@ public class WebSecurityConfig extends WebSecurityConfigurerAdapter { @Autowired private LogoutSuccessService logoutSuccessService; + @Autowired + private RolesAndPrivilegesConfig rolesAndPrivilegesConfig; + @Bean @Override public AuthenticationManager authenticationManagerBean() throws Exception { @@ -134,20 +142,18 @@ protected void configure(HttpSecurity http) throws Exception { disableCSRFURIs.removeAll(Arrays.asList("", null)); if (DEFAULT_ACTION_DENY.equals(getDefaultAction())) { - http.authorizeRequests().antMatchers(unprotectedURIs.toArray(new String[0])).permitAll().anyRequest() - .authenticated().and().formLogin().loginPage(loginPageURI).loginProcessingUrl(loginActionURI) - .successHandler(loginSuccessService).permitAll().and().logout().logoutUrl(logoutActionURI) - .invalidateHttpSession(true).logoutSuccessHandler(logoutSuccessService).deleteCookies("JSESSIONID") + http.authorizeRequests().antMatchers(unprotectedURIs.toArray(new String[0])).permitAll().anyRequest().authenticated().and().formLogin() + .loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll().and().logout() + .logoutUrl(logoutActionURI).invalidateHttpSession(true).logoutSuccessHandler(logoutSuccessService).deleteCookies("JSESSIONID") .permitAll(); if (disableCSRFURIs != null && disableCSRFURIs.size() > 0) { http.csrf().ignoringAntMatchers(disableCSRFURIs.toArray(new String[0])); } } else if (DEFAULT_ACTION_ALLOW.equals(getDefaultAction())) { - http.authorizeRequests().antMatchers(protectedURIsArray).authenticated().antMatchers("/**").permitAll() - .and().formLogin().loginPage(loginPageURI).loginProcessingUrl(loginActionURI) - .successHandler(loginSuccessService).successHandler(loginSuccessService).and().logout() - .logoutUrl(logoutActionURI).invalidateHttpSession(true).logoutSuccessHandler(logoutSuccessService) - .deleteCookies("JSESSIONID").permitAll(); + http.authorizeRequests().antMatchers(protectedURIsArray).authenticated().antMatchers("/**").permitAll().and().formLogin() + .loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService) + .successHandler(loginSuccessService).and().logout().logoutUrl(logoutActionURI).invalidateHttpSession(true) + .logoutSuccessHandler(logoutSuccessService).deleteCookies("JSESSIONID").permitAll(); if (disableCSRFURIs != null && disableCSRFURIs.size() > 0) { http.csrf().ignoringAntMatchers(disableCSRFURIs.toArray(new String[0])); @@ -178,4 +184,25 @@ public PasswordEncoder encoder() { public SessionRegistry sessionRegistry() { return new SessionRegistryImpl(); } -} \ No newline at end of file + + @Bean + public RoleHierarchy roleHierarchy() { + RoleHierarchyImpl roleHierarchy = new RoleHierarchyImpl(); + roleHierarchy.setHierarchy(rolesAndPrivilegesConfig.getRoleHierarchyString()); + log.debug("WebSecurityConfig.roleHierarchy:" + "roleHierarchy: {}", roleHierarchy.toString()); + return roleHierarchy; + } + + @Bean + public SecurityExpressionHandler webExpressionHandler() { + DefaultWebSecurityExpressionHandler defaultWebSecurityExpressionHandler = new DefaultWebSecurityExpressionHandler(); + defaultWebSecurityExpressionHandler.setRoleHierarchy(roleHierarchy()); + return defaultWebSecurityExpressionHandler; + } + + @Bean + public HttpSessionEventPublisher httpSessionEventPublisher() { + return new HttpSessionEventPublisher(); + } + +} diff --git a/src/main/resources/application-dev.properties b/src/main/resources/application-dev.properties deleted file mode 100644 index dada309..0000000 --- a/src/main/resources/application-dev.properties +++ /dev/null @@ -1,15 +0,0 @@ -debug=true - -logging.level.com.digitalsanctuary.spring=DEBUG -logging.level.org.springframework.web=DEBUG -logging.level.org.springframework.security=DEBUG -spring.http.log-request-details=true -logging.level.org.springframework.web.filter.CommonsRequestLoggingFilter=DEBUG - - - -spring.thymeleaf.cache: false -spring.devtools.restart.enabled=true -server.servlet.session.cookie.secure=false - -user.audit.flushOnWrite=true diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml new file mode 100644 index 0000000..07680bf --- /dev/null +++ b/src/main/resources/application-dev.yml @@ -0,0 +1,32 @@ +debug: true + +logging: + level: + com: + digitalsanctuary: + spring: DEBUG + org: + springframework: + security: DEBUG + web: + filter: + CommonsRequestLoggingFilter: DEBUG + nodeValue: DEBUG +spring: + mvc: + log-request-details: true + thymeleaf: + cache: false + devtools: + restart: + enabled: true + +server: + servlet: + session: + cookie: + secure: false +user: + audit: + flushOnWrite: true + diff --git a/src/main/resources/application-prd.properties b/src/main/resources/application-prd.yml similarity index 100% rename from src/main/resources/application-prd.properties rename to src/main/resources/application-prd.yml diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index c2d4a99..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1,83 +0,0 @@ -spring.application.name = User Framework - -spring.thymeleaf.template-loader-path: classpath:/templates -spring.thymeleaf.suffix: .html -spring.thymeleaf.cache: false - - -spring.messages.basename=messages/messages - - -hibernate.globally_quoted_identifiers=false -spring.jpa.properties.hibernate.globally_quoted_identifiers=false - -server.servlet.session.timeout=30m -server.servlet.session.cookie.http-only=true -server.servlet.session.cookie.secure=true - - -spring.mail.host=email-smtp.us-west-2.amazonaws.com -spring.mail.username= -spring.mail.password= -spring.mail.properties.mail.transport.protocol=smtp -spring.mail.properties.mail.smtp.port=587 -spring.mail.properties.mail.smtp.auth=true -spring.mail.properties.mail.smtp.starttls.enable=true -spring.mail.properties.mail.smtp.starttls.required=true - - -management.metrics.export.newrelic.apiKey= -management.metrics.export.newrelic.accountId= - -// deny or allow -user.security.defaultAction=deny -// Used if default is allow -user.security.protectedURIs=/protected.html -// Used if default is deny -user.security.unprotectedURIs=/,/index.html,/favicon.ico,/css/*,/js/*,/img/*,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword - -// URIs to disable CSRF checks. This might include API endpoints used by external clients. -user.security.disableCSRFdURIs=/no-csrf-test - - -// Centralizing the URIs of common pages to make changing paths easier. You can leave this section alone if you use the default page locations from this project. These URLs do NOT have to be included in the unprotectedURIs list above as they will automatically be handled. -user.security.loginPageURI=/user/login.html -user.security.loginActionURI=/user/login -user.security.loginSuccessURI=/index.html?messageKey=message.loginSuccess -user.security.logoutActionURI=/user/logout -user.security.logoutSuccessURI=/index.html?messageKey=message.logoutSuccess -user.security.registrationURI=/user/register.html -user.security.registrationPendingURI=/user/registration-pending-verification.html -user.security.registrationSuccessURI=/user/registration-complete.html -user.security.registrationNewVerificationURI=/user/request-new-verification-email.html -user.security.forgotPasswordURI=/user/forgot-password.html -user.security.forgotPasswordPendingURI=/user/forgot-password-pending-verification.html -user.security.forgotPasswordChangeURI=/user/forgot-password-change.html -user.security.updateUserURI=/user/update-user.html - -user.copyrightFirstYear=2020 - -user.mail.fromAddress=test@test.com - -user.purgetokens.cron.expression=0 0 3 * * ? - -user.registration.sendVerificationEmail=true - -user.audit.logEvents=true -user.audit.flushOnWrite=false -user.audit.logFilePath=/opt/app/logs/user-audit.log - -logging.file.name=/opt/app/logs/user-app.log - - -##### MySQL -#################### DataSource Configuration ########################## -spring.datasource.url=jdbc:mysql://localhost:3306/springuser?createDatabaseIfNotExist=true -spring.datasource.driverClassName=org.mariadb.jdbc.Driver -spring.datasource.username=springuser -spring.datasource.password=springuser -#################### Hibernate Configuration ########################## -spring.jpa.show-sql=false -spring.jpa.hibernate.ddl-auto=update -spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.MariaDB103Dialect - diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..bac93b7 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,108 @@ +spring: + mail: + username: + password: + properties: + mail: + smtp: + starttls: + enable: true + required: true + auth: true + port: 587 + transport: + protocol: smtp + host: email-smtp.us-west-2.amazonaws.com + + thymeleaf: + cache: false + template-loader-path: classpath:/templates + suffix: .html + jpa: + hibernate: + ddl-auto: update + properties: + hibernate: + dialect: org.hibernate.dialect.MariaDB103Dialect + globally_quoted_identifiers: false + show-sql: 'false' + application: + name: User Framework + datasource: + password: springuser + url: jdbc:mariadb://localhost:3306/springuser?createDatabaseIfNotExist=true + driverClassName: org.mariadb.jdbc.Driver + username: springuser + messages: + basename: messages/messages +user: + registration: + sendVerificationEmail: true + audit: + logFilePath: /opt/app/logs/user-audit.log + flushOnWrite: false + logEvents: true + +# Centralizing the URIs of common pages to make changing paths easier. You can leave this section alone if you use the default page locations from this project. These URLs do NOT have to be included in the unprotectedURIs list above as they will automatically be handled. + security: + loginActionURI: /user/login + forgotPasswordChangeURI: /user/forgot-password-change.html + registrationNewVerificationURI: /user/request-new-verification-email.html + loginSuccessURI: /index.html?messageKey=message.loginSuccess + forgotPasswordURI: /user/forgot-password.html + registrationPendingURI: /user/registration-pending-verification.html + disableCSRFdURIs: /no-csrf-test + registrationURI: /user/register.html + registrationSuccessURI: /user/registration-complete.html + logoutSuccessURI: /index.html?messageKey=message.logoutSuccess + defaultAction: deny + loginPageURI: /user/login.html + logoutActionURI: /user/logout + unprotectedURIs: /,/index.html,/favicon.ico,/css/*,/js/*,/img/*,/user/registration,/user/resendRegistrationToken,/user/resetPassword,/user/registrationConfirm,/user/changePassword,/user/savePassword + protectedURIs: /protected.html + forgotPasswordPendingURI: /user/forgot-password-pending-verification.html + updateUserURI: /user/update-user.html + mail: + fromAddress: test@test.com + purgetokens: + cron: + expression: 0 0 3 * * ? + copyrightFirstYear: 2020 + + roles-and-privileges: + ROLE_ADMIN: + - ADMIN_PRIVILEGE + - INVITE_USER_PRIVILEGE + - READ_USER_PRIVILEGE + - ASSIGN_MANAGER_PRIVILEGE + - RESET_ANY_USER_PASSWORD_PRIVILEGE + ROLE_MANAGER: + - ADD_USER_TO_TEAM_PRIVILEGE + - REMOVE_USER_FROM_TEAM_PRIVILEGE + - RESET_TEAM_PASSWORD_PRIVILEGE + ROLE_USER: + - LOGIN_PRIVILEGE + - UPDATE_OWN_USER_PRIVILEGE + - RESET_OWN_PASSWORD_PRIVILEGE + role-hierarchy: + - ROLE_ADMIN > ROLE_MANAGER + - ROLE_MANAGER > ROLE_USER + +management: + metrics: + export: + newrelic: + accountId: + apiKey: +hibernate: + globally_quoted_identifiers: 'false' +server: + servlet: + session: + cookie: + secure: true + http-only: true + timeout: 30m +logging: + file: + name: /opt/app/logs/user-app.log