diff --git a/backend/.gitignore b/backend/.gitignore new file mode 100755 index 0000000..c860d75 --- /dev/null +++ b/backend/.gitignore @@ -0,0 +1,11 @@ +bin/ +target/ +.settings/ +.project +.classpath +hs_err*.log +application.properties +usernames.txt +lucene + +.vscode \ No newline at end of file diff --git a/backend/pom.xml b/backend/pom.xml new file mode 100644 index 0000000..8b3c354 --- /dev/null +++ b/backend/pom.xml @@ -0,0 +1,138 @@ + + + 4.0.0 + de.champonthis + buntspecht + ${revision} + + + UTF-8 + 17 + + ${java.version} + ${java.version} + 5.1.0 + 0.4.0 + + + + org.springframework.boot + spring-boot-starter-parent + 3.3.4 + + + + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-security + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-webflux + + + + org.springframework.boot + spring-boot-starter-mail + + + + org.springframework.session + spring-session-jdbc + + + + org.springframework.boot + spring-boot-starter-oauth2-client + + + + + com.querydsl + querydsl-apt + ${querydsl.version} + jakarta + + + + com.querydsl + querydsl-jpa + ${querydsl.version} + jakarta + + + + + commons-validator + commons-validator + 1.9.0 + + + + com.google.code.gson + gson + + + + org.apache.commons + commons-lang3 + + + + org.bouncycastle + bcprov-jdk18on + 1.78.1 + + + + org.passay + passay + 1.6.5 + + + + + org.postgresql + postgresql + runtime + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + de.champonthis.buntspecht.Application + buntspecht + true + ZIP + + + + build-info + + build-info + + + + + + + \ No newline at end of file diff --git a/backend/src/main/java/de/champonthis/buntspecht/Application.java b/backend/src/main/java/de/champonthis/buntspecht/Application.java new file mode 100755 index 0000000..f9c9410 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/Application.java @@ -0,0 +1,16 @@ +package de.champonthis.buntspecht; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.web.servlet.support.SpringBootServletInitializer; + + +@SpringBootApplication +public class Application extends SpringBootServletInitializer { + + + public static void main(String[] args) { + SpringApplication.run(Application.class, args); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/JPAConfig.java b/backend/src/main/java/de/champonthis/buntspecht/JPAConfig.java new file mode 100644 index 0000000..3371d30 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/JPAConfig.java @@ -0,0 +1,24 @@ +package de.champonthis.buntspecht; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +import com.querydsl.jpa.impl.JPAQueryFactory; + +import jakarta.persistence.EntityManager; + +@Configuration +@EnableJpaAuditing +public class JPAConfig { + + @Autowired + private EntityManager em; + + @Bean + public JPAQueryFactory jpaQueryFactory() { + return new JPAQueryFactory(em); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/businesslogic/SystemPropertyManager.java b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/SystemPropertyManager.java new file mode 100755 index 0000000..e129f29 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/SystemPropertyManager.java @@ -0,0 +1,74 @@ +package de.champonthis.buntspecht.businesslogic; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.Assert; + +import de.champonthis.buntspecht.model.SystemProperty; +import de.champonthis.buntspecht.repository.SystemPropertyRepository; + +@Component +public class SystemPropertyManager { + + @Autowired + private SystemPropertyRepository systemPropertyRepository; + + public boolean has(String key) { + return systemPropertyRepository.existsById(key); + } + + public String get(String key) { + return systemPropertyRepository.findById(key).orElse(new SystemProperty()).getValue(); + } + + public String get(String key, String defaultValue) { + return systemPropertyRepository.findById(key).orElse(new SystemProperty(key, defaultValue)).getValue(); + } + + public boolean getBoolean(String key) { + return getBoolean(key, false); + } + + public boolean getBoolean(String key, boolean defaultValue) { + return Boolean.valueOf(get(key, String.valueOf(defaultValue))); + } + + public int getInteger(String key) { + return getInteger(key, 0); + } + + public int getInteger(String key, int defaultValue) { + return Integer.valueOf(get(key, String.valueOf(defaultValue))); + } + + public long getLong(String key) { + return getLong(key, 0L); + } + + public long getLong(String key, long defaultValue) { + return Long.valueOf(get(key, String.valueOf(defaultValue))); + } + + public void add(String key, String value) { + Assert.isTrue(!systemPropertyRepository.existsById(key), + "System Property already exists, use update method to change value!"); + systemPropertyRepository.save(new SystemProperty(key, value)); + } + + public void update(String key, String value) { + Assert.isTrue(systemPropertyRepository.existsById(key), + "System Property does not exists, use add method to add new!"); + SystemProperty systemProperty = systemPropertyRepository.findById(key).get(); + systemProperty.setValue(value); + systemPropertyRepository.save(systemProperty); + } + + public void set(String key, String value) { + if (systemPropertyRepository.existsById(key)) { + update(key, value); + } else { + add(key, value); + } + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/businesslogic/TurnoverManager.java b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/TurnoverManager.java new file mode 100644 index 0000000..81715cd --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/TurnoverManager.java @@ -0,0 +1,202 @@ +package de.champonthis.buntspecht.businesslogic; + +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.QueryResults; +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.Predicate; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import de.champonthis.buntspecht.controller.model.TurnoverFilterModel; +import de.champonthis.buntspecht.model.QTurnover; +import de.champonthis.buntspecht.model.Turnover; +import de.champonthis.buntspecht.repository.TurnoverRepository; + +@Service +public class TurnoverManager { + + @Autowired + private TurnoverRepository turnoverRepository; + @Autowired + private JPAQueryFactory jpaQueryFactory; + + private QTurnover qTurnover = QTurnover.turnover; + + public QueryResults fetch(long limit, long offset, String sortBy, boolean descending, + TurnoverFilterModel filter) { + return fetch(null, limit, offset, sortBy, descending, filter); + } + + public QueryResults fetch(String username, long limit, long offset, String sortBy, boolean descending, + TurnoverFilterModel filter) { + BooleanBuilder builder = new BooleanBuilder(); + + if (StringUtils.hasText(username)) { + builder.and(qTurnover.username.eq(username)); + } + + builder.and(buildFilter(filter)); + + JPAQuery query = jpaQueryFactory.from(qTurnover).where(builder.getValue()).select(qTurnover); + Long total = query.clone().select(qTurnover.id.countDistinct()).fetchOne(); + + if (StringUtils.hasText(sortBy)) { + Path> path = null; + switch (sortBy) { + case "created": + path = qTurnover.created; + break; + case "dueDate": + path = qTurnover.dueDate; + break; + case "updated": + path = qTurnover.updated; + break; + case "customer": + path = qTurnover.customer; + break; + case "price": + path = qTurnover.price; + break; + case "timeInvestment": + path = qTurnover.timeInvestment; + break; + } + if (path != null) { + query.orderBy(new OrderSpecifier<>(descending ? Order.DESC : Order.ASC, path)); + } + } + + List result = query.limit(limit).offset(offset).fetch(); + return new QueryResults(result, limit, offset, total == null ? 0L : total); + + } + + protected Predicate buildFilter(TurnoverFilterModel filter) { + BooleanBuilder builder = new BooleanBuilder(); + + if (filter != null) { + if (filter.getCreated() != null) { + if (filter.getCreated().getMin() != null) { + builder.and(qTurnover.created.after(filter.getCreated().getMin())); + } + if (filter.getCreated().getMax() != null) { + builder.and(qTurnover.created.before(filter.getCreated().getMax())); + } + } + if (filter.getDueDate() != null) { + if (filter.getDueDate().getMin() != null) { + builder.and(qTurnover.dueDate.after(filter.getDueDate().getMin())); + } + if (filter.getDueDate().getMax() != null) { + builder.and(qTurnover.dueDate.before(filter.getDueDate().getMax())); + } + } + if (filter.getUpdated() != null) { + if (filter.getUpdated().getMin() != null) { + builder.and(qTurnover.updated.after(filter.getUpdated().getMin())); + } + if (filter.getUpdated().getMax() != null) { + builder.and(qTurnover.updated.before(filter.getUpdated().getMax())); + } + } + if (filter.getCustomer() != null) { + builder.and(qTurnover.customer.contains(filter.getCustomer())); + } + if (filter.getMotif() != null) { + builder.and(qTurnover.motif.contains(filter.getMotif())); + } + if (filter.getPrice() != null) { + if (filter.getPrice().getMin() != null) { + builder.and(qTurnover.price.goe(filter.getPrice().getMin())); + } + if (filter.getPrice().getMax() != null) { + builder.and(qTurnover.price.loe(filter.getPrice().getMax())); + } + } + if (filter.getTimeInvestment() != null) { + if (filter.getTimeInvestment().getMin() != null) { + builder.and(qTurnover.timeInvestment.goe(filter.getTimeInvestment().getMin())); + } + if (filter.getTimeInvestment().getMax() != null) { + builder.and(qTurnover.timeInvestment.loe(filter.getTimeInvestment().getMax())); + } + } + } + + return builder.getValue(); + } + + public Turnover get(Long id) { + return turnoverRepository.findById(id).orElse(null); + } + + public Turnover save(Turnover turnover) { + return turnoverRepository.save(turnover); + } + + public boolean exists(Long id) { + return turnoverRepository.existsById(id); + } + + public void delete(Turnover turnover) { + turnoverRepository.delete(turnover); + } + + public void deleteById(Long id) { + turnoverRepository.deleteById(id); + } + + public void deleteByUsername(String username) { + turnoverRepository.deleteAllInBatch(turnoverRepository.findAll(qTurnover.username.eq(username))); + } + + public QueryResults overview(String username, long limit, long offset, String sortBy, boolean descending, + TurnoverFilterModel filter) { + BooleanBuilder builder = new BooleanBuilder(); + + if (StringUtils.hasText(username)) { + builder.and(qTurnover.username.eq(username)); + } + + builder.and(buildFilter(filter)); + + JPAQuery query = jpaQueryFactory.from(qTurnover).where(builder.getValue()).groupBy(qTurnover.username) + .select(qTurnover.username.as("username"), qTurnover.price.sum().as("price"), + qTurnover.timeInvestment.sum().as("timeInvestment")); + Long total = query.clone().select(qTurnover.username.countDistinct()).fetchOne(); + + if (StringUtils.hasText(sortBy)) { + Path> path = null; + switch (sortBy) { + case "username": + path = qTurnover.username; + break; + case "price": + path = qTurnover.price; + break; + case "timeInvestment": + path = qTurnover.timeInvestment; + break; + } + if (path != null) { + query.orderBy(new OrderSpecifier<>(descending ? Order.DESC : Order.ASC, path)); + } + } + + List result = query.limit(limit).offset(offset) + .fetch(); + + return new QueryResults(result, limit, offset, total == null ? 0L : total); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/businesslogic/UserManager.java b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/UserManager.java new file mode 100644 index 0000000..d918cdf --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/businesslogic/UserManager.java @@ -0,0 +1,211 @@ +package de.champonthis.buntspecht.businesslogic; + +import java.util.ArrayList; +import java.util.List; + +import org.apache.commons.lang3.RandomStringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +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.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; +import org.springframework.stereotype.Service; +import org.springframework.util.Assert; +import org.springframework.util.StringUtils; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.core.QueryResults; +import com.querydsl.core.types.Order; +import com.querydsl.core.types.OrderSpecifier; +import com.querydsl.core.types.Path; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; + +import de.champonthis.buntspecht.model.QUser; +import de.champonthis.buntspecht.model.User; +import de.champonthis.buntspecht.repository.UserRepository; +import de.champonthis.buntspecht.security.LocalUserDetails; +import jakarta.transaction.Transactional; + +@Service +public class UserManager implements UserDetailsService, SmartInitializingSingleton { + + private Logger logger = LoggerFactory.getLogger(UserManager.class); + + @Autowired + private UserRepository userRepository; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private JPAQueryFactory jpaQueryFactory; + @Autowired + private TurnoverManager turnoverManager; + private QUser qUser = QUser.user; + + @Value("${admin.password:}") + private String adminPassword; + + public QueryResults fetch(long limit, long offset, String sortBy, boolean descending, String usernameFilter) { + BooleanBuilder builder = new BooleanBuilder(); + + if (StringUtils.hasText(usernameFilter)) { + builder.and(qUser.username.contains(usernameFilter)); + } + + JPAQuery query = jpaQueryFactory.from(qUser).where(builder.getValue()).select(qUser); + Long total = query.clone().select(qUser.username.countDistinct()).fetchOne(); + + if (StringUtils.hasText(sortBy)) { + Path> path = null; + switch (sortBy) { + case "username": + path = qUser.username; + break; + case "name": + path = qUser.name; + break; + } + if (path != null) { + query.orderBy(new OrderSpecifier<>(descending ? Order.DESC : Order.ASC, path)); + } + } + + List result = query.limit(limit).offset(offset).fetch(); + return new QueryResults(result, limit, offset, total == null ? 0L : total); + } + + @Override + @Transactional + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = getByUsername(username); + + if (user == null) { + throw new UsernameNotFoundException(username); + } + + List authorities = new ArrayList<>(); + if (user.getRoles() != null) { + for (String role : user.getRoles()) { + authorities.add(new SimpleGrantedAuthority(role)); + } + } + + String passwordHash = user.getPasswordHash(); + + if (passwordHash == null) { + passwordHash = ""; + } + + LocalUserDetails userDetails = new LocalUserDetails(username, passwordHash, authorities); + return userDetails; + } + + @Override + public void afterSingletonsInstantiated() { + if (!userRepository.exists(qUser.roles.contains("ROLE_ADMIN"))) { + if (!StringUtils.hasText(adminPassword)) { + adminPassword = RandomStringUtils.random(24, true, true); + logger.error("password for 'admin': " + adminPassword); + } + User admin = new User(); + admin.setUsername("admin"); + admin.setRoles(List.of("ROLE_ADMIN", "ROLE_DEBUG")); + admin.setPasswordHash(passwordEncoder.encode(adminPassword)); + admin.setLocale("de"); + userRepository.save(admin); + } + } + + @Transactional + public User getByUsername(String username) { + return userRepository.findOne(qUser.username.equalsIgnoreCase(username)).orElse(null); + } + + public User getByExternalId(String externalId) { + return userRepository.findOne(qUser.externalId.eq(externalId)).orElse(null); + } + + public User getByAuth(Authentication authentication) { + if (authentication != null) { + if (authentication instanceof UsernamePasswordAuthenticationToken) { + UsernamePasswordAuthenticationToken token = (UsernamePasswordAuthenticationToken) authentication; + return getByUsername(token.getName()); + } else if (authentication instanceof OAuth2AuthenticationToken) { + OAuth2AuthenticationToken token = (OAuth2AuthenticationToken) authentication; + String externalId = token.getAuthorizedClientRegistrationId() + "-" + + token.getName(); + User user = getByExternalId(externalId); + if (user == null) { + user = new User(); + user.setExternalId(externalId); + String tmpUsername = token.getPrincipal().getAttribute("preferred_username"); + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = token.getPrincipal().getAttribute("username"); + } + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = token.getPrincipal().getAttribute("name"); + } else { + user.setName(token.getPrincipal().getAttribute("name")); + } + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = token.getName(); + } + if (!StringUtils.hasText(tmpUsername)) { + tmpUsername = "user"; + } + int count = 1; + String username = tmpUsername; + while (userRepository.exists(qUser.username.equalsIgnoreCase(username))) { + username = tmpUsername + "-" + count; + count++; + } + + user.setUsername(username); + user.setEmail(token.getPrincipal().getAttribute("email")); + user = userRepository.save(user); + } + return user; + } + } + return null; + } + + public User save(User user) { + if (exists(user.getUsername())) { + user.setPasswordHash(this.getPasswordHash(user.getUsername())); + } + + return userRepository.save(user); + } + + public boolean exists(String username) { + return userRepository.exists(qUser.username.equalsIgnoreCase(username)); + } + + public void delete(User user) { + turnoverManager.deleteByUsername(user.getUsername()); + userRepository.delete(user); + } + + @Transactional + public String getPasswordHash(String username) { + Assert.isTrue(userRepository.existsById(username), "User with username '" + username + "' not exists!"); + return userRepository.findById(username).get().getPasswordHash(); + } + + public User setPassword(String username, String password) { + Assert.isTrue(userRepository.existsById(username), "User with username '" + username + "' not exists!"); + User user = userRepository.findById(username).get(); + user.setPasswordHash(passwordEncoder.encode(password)); + return userRepository.save(user); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/AuthenticationController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/AuthenticationController.java new file mode 100755 index 0000000..9c8a654 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/AuthenticationController.java @@ -0,0 +1,85 @@ +package de.champonthis.buntspecht.controller; + +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.ResolvableType; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.client.registration.ClientRegistration; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.champonthis.buntspecht.controller.support.EntityResponseStatusException; +import de.champonthis.buntspecht.security.LocalUserDetails; + +@RestController +@RequestMapping("/auth") +public class AuthenticationController extends BaseController { + + private static String authorizationRequestBaseUri = "oauth2/authorization"; + + @Autowired(required = false) + private ClientRegistrationRepository clientRegistrationRepository; + + @GetMapping + public Object me() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.getPrincipal() instanceof LocalUserDetails) { + return (LocalUserDetails) auth.getPrincipal(); + } + + throw new EntityResponseStatusException(HttpStatus.UNAUTHORIZED); + } + + @SuppressWarnings("unchecked") + @GetMapping("external") + public List getExternalLoginUrls() { + List clients = new ArrayList<>(); + if (clientRegistrationRepository != null) { + Iterable clientRegistrations = null; + ResolvableType type = ResolvableType.forInstance(clientRegistrationRepository).as(Iterable.class); + if (type != ResolvableType.NONE && ClientRegistration.class.isAssignableFrom(type.resolveGenerics()[0])) { + clientRegistrations = (Iterable) clientRegistrationRepository; + clientRegistrations.forEach(registration -> clients.add(new Client(registration.getRegistrationId(), + authorizationRequestBaseUri + "/" + registration.getRegistrationId()))); + } + } + + return clients; + } + + protected static class Client { + + private String id; + + private String loginUrl; + + public Client(String id, String loginUrl) { + super(); + this.id = id; + this.loginUrl = loginUrl; + } + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLoginUrl() { + return loginUrl; + } + + public void setLoginUrl(String loginUrl) { + this.loginUrl = loginUrl; + } + + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/BaseController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/BaseController.java new file mode 100644 index 0000000..9d546f5 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/BaseController.java @@ -0,0 +1,32 @@ +package de.champonthis.buntspecht.controller; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; + +import de.champonthis.buntspecht.security.LocalUserDetails; + +public class BaseController { + + protected boolean authenticated() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return auth != null && auth.isAuthenticated(); + } + + protected String getCurrentUsername() { + LocalUserDetails userDetails = getLocalUserDetails(); + return userDetails != null ? userDetails.getUsername() : null; + } + + protected boolean hasRole(String role) { + LocalUserDetails userDetails = getLocalUserDetails(); + return userDetails != null ? userDetails.getAuthorities().contains(new SimpleGrantedAuthority(role)) : false; + } + + protected LocalUserDetails getLocalUserDetails() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + return (auth != null && auth.getPrincipal() instanceof LocalUserDetails) + ? (LocalUserDetails) auth.getPrincipal() + : null; + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/TurnoverController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/TurnoverController.java new file mode 100644 index 0000000..8c7f69d --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/TurnoverController.java @@ -0,0 +1,174 @@ +package de.champonthis.buntspecht.controller; + +import java.time.Instant; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.querydsl.core.QueryResults; +import com.querydsl.core.Tuple; + +import de.champonthis.buntspecht.businesslogic.TurnoverManager; +import de.champonthis.buntspecht.businesslogic.UserManager; +import de.champonthis.buntspecht.controller.model.TurnoverFilterModel; +import de.champonthis.buntspecht.controller.model.TurnoverFilterModel.MinMax; +import de.champonthis.buntspecht.controller.support.EntityResponseStatusException; +import de.champonthis.buntspecht.controller.support.RequestBodyErrors; +import de.champonthis.buntspecht.controller.validation.TurnoverValidator; +import de.champonthis.buntspecht.model.Turnover; +import jakarta.transaction.Transactional; + +@RestController +@RequestMapping("/turnovers") +public class TurnoverController extends BaseController { + + @Autowired + private TurnoverManager turnoverManager; + @Autowired + private UserManager userManager; + @Autowired + private TurnoverValidator turnoverValidator; + + @PreAuthorize("isAuthenticated()") + @GetMapping + @Transactional + public QueryResults fetch( + @RequestParam("limit") Optional limitParameter, + @RequestParam("offset") Optional offsetParameter, + @RequestParam("sort") Optional sort, + @RequestParam("descending") Optional descending, + @RequestParam("from") Optional from, + @RequestParam("to") Optional to, + @RequestParam("created_from") Optional fromCreated, + @RequestParam("created_to") Optional toCreated, + @RequestParam("customer") Optional customer, + @RequestParam("motif") Optional motif) { + + TurnoverFilterModel filter = new TurnoverFilterModel(); + filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null))); + filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null))); + filter.setCustomer(customer.orElse(null)); + filter.setMotif(motif.orElse(null)); + + return turnoverManager.fetch(getCurrentUsername(), limitParameter.orElse(15L), offsetParameter.orElse(0L), + sort.orElse("dueDate"), descending.orElse(false), filter); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/overview") + @Transactional + public Tuple overview( + @RequestParam("limit") Optional limitParameter, + @RequestParam("offset") Optional offsetParameter, + @RequestParam("sort") Optional sort, + @RequestParam("descending") Optional descending, + @RequestParam("from") Optional from, + @RequestParam("to") Optional to, + @RequestParam("created_from") Optional fromCreated, + @RequestParam("created_to") Optional toCreated, + @RequestParam("customer") Optional customer, + @RequestParam("motif") Optional motif) { + + TurnoverFilterModel filter = new TurnoverFilterModel(); + filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null))); + filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null))); + filter.setCustomer(customer.orElse(null)); + filter.setMotif(motif.orElse(null)); + + List result = turnoverManager.overview(getCurrentUsername(), limitParameter.orElse(15L), + offsetParameter.orElse(0L), sort.orElse("username"), + descending.orElse(false), filter).getResults(); + + if (result.isEmpty()) { + return null; + } + + return result.get(0); + } + + @PreAuthorize("isAuthenticated()") + @GetMapping("/{id}") + @Transactional + public Turnover get(@PathVariable("id") Long id) { + Turnover turnover = turnoverManager.get(id); + + if (turnover == null || !getCurrentUsername().equals(turnover.getUsername())) { + throw new EntityResponseStatusException(HttpStatus.FORBIDDEN); + } + + return turnover; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping + @Transactional + public Turnover create(@RequestBody Turnover turnover) { + Errors errors = new RequestBodyErrors(turnover); + turnoverValidator.validate(turnover, errors); + + if (errors.hasErrors()) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + if (!hasRole("ROLE_ADMIN") || !StringUtils.hasText(turnover.getUsername()) + || !userManager.exists(turnover.getUsername())) { + turnover.setUsername(getCurrentUsername()); + } + turnover.setCreated(Instant.now()); + turnover.setUpdated(turnover.getCreated()); + if (turnover.getDueDate() == null) { + turnover.setDueDate(turnover.getCreated()); + } + + return turnoverManager.save(turnover); + } + + @PreAuthorize("isAuthenticated()") + @PatchMapping + @Transactional + public Turnover update(@RequestBody Turnover turnover) { + Errors errors = new RequestBodyErrors(turnover); + turnoverValidator.validate(turnover, errors); + + if (errors.hasErrors() || turnover.getId() == null || turnover.getId() == 0L + || !turnoverManager.exists(turnover.getId())) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + if (!hasRole("ROLE_ADMIN")) { + Turnover existing = turnoverManager.get(turnover.getId()); + if (!getCurrentUsername().equals(existing.getUsername())) { + throw new EntityResponseStatusException(HttpStatus.FORBIDDEN); + } + turnover.setUsername(getCurrentUsername()); + } + + Turnover existing = turnoverManager.get(turnover.getId()); + + if (existing.equals(turnover)) { + throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED); + } + + if (turnover.getDueDate() == null) { + turnover.setDueDate(turnover.getCreated()); + } + + turnover.setUpdated(Instant.now()); + + return turnoverManager.save(turnover); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/UserController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/UserController.java new file mode 100644 index 0000000..fb2f08f --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/UserController.java @@ -0,0 +1,84 @@ +package de.champonthis.buntspecht.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.champonthis.buntspecht.businesslogic.UserManager; +import de.champonthis.buntspecht.controller.model.UserPasswordModel; +import de.champonthis.buntspecht.controller.support.EntityResponseStatusException; +import de.champonthis.buntspecht.controller.support.RequestBodyErrors; +import de.champonthis.buntspecht.controller.validation.PasswordModelValidator; +import de.champonthis.buntspecht.model.User; +import jakarta.transaction.Transactional; + +@RestController +@RequestMapping("/users") +public class UserController extends BaseController { + + @Autowired + private UserManager userManager; + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private PasswordModelValidator passwordModelValidator; + + @PreAuthorize("isAuthenticated()") + @GetMapping("/user") + @Transactional + public User get() { + return userManager.getByUsername(getCurrentUsername()); + } + + @PreAuthorize("isAuthenticated()") + @PatchMapping("/user") + @Transactional + public User updateUser(@RequestBody User user) { + if (!getCurrentUsername().equals(user.getUsername())) { + throw new EntityResponseStatusException(HttpStatus.UNPROCESSABLE_ENTITY); + } + + User orgUser = userManager.getByUsername(user.getUsername()); + + orgUser.setName(user.getName()); + orgUser.setAbout(user.getAbout()); + orgUser.setDarkTheme(user.isDarkTheme()); + orgUser.setEmail(user.getEmail()); + orgUser.setLocale(user.getLocale()); + + user = userManager.save(orgUser); + return user; + } + + @PreAuthorize("isAuthenticated()") + @PostMapping("/password") + public void changePassword(@RequestBody UserPasswordModel passwordModel) { + + Errors errors = new RequestBodyErrors(passwordModel); + + User user = userManager.getByUsername(getCurrentUsername()); + + if (!StringUtils.hasText(passwordModel.getOld()) + || !passwordEncoder.matches(passwordModel.getOld(), userManager.getPasswordHash(user.getUsername()))) { + errors.rejectValue("old", "UNAUTHORIZED"); + } + + passwordModelValidator.validate(passwordModel, errors); + + if (errors.hasErrors()) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + userManager.setPassword(user.getUsername(), passwordModel.getPassword()); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/DebugController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/DebugController.java new file mode 100644 index 0000000..a641c42 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/DebugController.java @@ -0,0 +1,118 @@ +package de.champonthis.buntspecht.controller.admin; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.SplittableRandom; + +import org.apache.commons.lang3.RandomStringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.champonthis.buntspecht.model.QUser; +import de.champonthis.buntspecht.model.Turnover; +import de.champonthis.buntspecht.model.User; +import de.champonthis.buntspecht.repository.TurnoverRepository; +import de.champonthis.buntspecht.repository.UserRepository; + +@RestController +@RequestMapping("/debug") +public class DebugController { + + private Logger logger = LoggerFactory.getLogger(DebugController.class); + + @Autowired + private PasswordEncoder passwordEncoder; + @Autowired + private UserRepository userRepository; + @Autowired + private TurnoverRepository turnoverRepository; + + SplittableRandom splittableRandom = new SplittableRandom(); + + @PreAuthorize("hasRole('ROLE_DEBUG')") + @GetMapping("/random") + public void random( + @RequestParam("users") Optional usersParameter, + @RequestParam("minEntries") Optional minEntriesParameter, + @RequestParam("maxEntries") Optional maxEntriesParameter, + @RequestParam("days") Optional daysParameter) { + logger.warn("start random generation"); + + long userCount = userRepository.count(QUser.user.username.startsWith("Tätowier")); + + List newUser = new ArrayList<>(); + + for (long i = userCount + 1; i <= userCount + usersParameter.orElse(5); i++) { + User user = new User(); + String username = (splittableRandom.nextBoolean() ? "Tätowiererin " : "Tätowierer ") + i; + String name = "Random " + RandomStringUtils.randomAlphanumeric(splittableRandom.nextInt(4, 8)); + user.setUsername(username); + user.setName(name); + user.setPasswordHash(passwordEncoder.encode(username)); + user = userRepository.save(user); + logger.trace("Created user: '" + username + "'"); + newUser.add(user.getUsername()); + } + + logger.info("Created " + usersParameter.orElse(5) + " users"); + + Instant startInclusive = Instant.now().minus(daysParameter.orElse(350), ChronoUnit.DAYS); + Instant endExclusive = Instant.now(); + + for (String username : newUser) { + long numEntries = splittableRandom.nextLong(minEntriesParameter.orElse(3), maxEntriesParameter.orElse(20)); + for (int i = 0; i < numEntries; i++) { + Turnover turnover = new Turnover(); + turnover.setUsername(username); + turnover.setCreated(randomDate(startInclusive, endExclusive)); + turnover.setUpdated(splittableRandom.nextBoolean() ? turnover.getCreated() + : randomDate(turnover.getCreated(), endExclusive)); + turnover.setDueDate(splittableRandom.nextBoolean() ? turnover.getCreated() + : randomDate(startInclusive, turnover.getCreated())); + turnover.setCustomer(RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(3, 10))); + + turnover.setMotif(RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(5, 30))); + + turnover.setPrice(Float.valueOf(String.format("%.2f", splittableRandom.nextFloat(0.01f, 2000f)))); + + if (splittableRandom.nextBoolean()) { + turnover.setTimeInvestment( + Float.valueOf(String.format("%.2f", splittableRandom.nextFloat(0.01f, 20f)))); + } + + if (splittableRandom.nextBoolean()) { + turnover.setRemark(RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(10, 50))); + } + + if (splittableRandom.nextInt(5) < 3) { + turnover.setMaterialConsumption( + RandomStringUtils.randomAlphabetic(splittableRandom.nextInt(10, 250))); + } + + turnover = turnoverRepository.save(turnover); + logger.trace("Created turnover: '" + turnover.getId() + "'"); + } + logger.info("Created " + numEntries + " turnovers of '" + username + "'"); + } + + logger.warn("finished random generation"); + } + + protected Instant randomDate(Instant startInclusive, Instant endExclusive) { + long startSeconds = startInclusive.getEpochSecond(); + long endSeconds = endExclusive.getEpochSecond(); + long random = splittableRandom.nextLong(startSeconds, endSeconds); + + return Instant.ofEpochSecond(random); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/SystemPropertiesController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/SystemPropertiesController.java new file mode 100644 index 0000000..3712cac --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/SystemPropertiesController.java @@ -0,0 +1,78 @@ +package de.champonthis.buntspecht.controller.admin; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import de.champonthis.buntspecht.controller.BaseController; +import de.champonthis.buntspecht.controller.support.EntityResponseStatusException; +import de.champonthis.buntspecht.model.SystemProperty; +import de.champonthis.buntspecht.repository.SystemPropertyRepository; + +@RestController +@RequestMapping("/system/properties") +public class SystemPropertiesController extends BaseController { + + @Autowired + private SystemPropertyRepository systemPropertyRepository; + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping() + public List getProperties(@RequestParam("page") Optional pageParameter, + @RequestParam("size") Optional sizeParameter) { + Sort sort = Sort.by("key").ascending(); + return systemPropertyRepository.findAll(PageRequest.of(pageParameter.orElse(0), sizeParameter.orElse(10), sort)) + .getContent(); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/{key}") + public SystemProperty getProperty(@PathVariable("key") String key) { + if (!systemPropertyRepository.existsById(key)) { + throw new EntityResponseStatusException(HttpStatus.NO_CONTENT); + } + + return systemPropertyRepository.findById(key).get(); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("") + public SystemProperty createOrUpdate(@RequestBody SystemProperty systemProperty) { + return systemPropertyRepository.save(systemProperty); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/list") + public List createOrUpdateList(@RequestBody List systemProperties) { + List result = new ArrayList<>(); + for (SystemProperty systemProperty : systemProperties) { + result.add( + systemPropertyRepository.save(systemProperty)); + } + return result; + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/{key}") + public void deleteProperty(@PathVariable("key") String key) { + if (!systemPropertyRepository.existsById(key)) { + throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED); + } + + systemPropertyRepository.deleteById(key); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/TurnoverManagementController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/TurnoverManagementController.java new file mode 100644 index 0000000..144c138 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/TurnoverManagementController.java @@ -0,0 +1,139 @@ +package de.champonthis.buntspecht.controller.admin; + +import java.time.Instant; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.querydsl.core.QueryResults; +import com.querydsl.core.Tuple; + +import de.champonthis.buntspecht.businesslogic.TurnoverManager; +import de.champonthis.buntspecht.controller.BaseController; +import de.champonthis.buntspecht.controller.model.TurnoverFilterModel; +import de.champonthis.buntspecht.controller.model.TurnoverFilterModel.MinMax; +import de.champonthis.buntspecht.controller.support.EntityResponseStatusException; +import de.champonthis.buntspecht.controller.support.RequestBodyErrors; +import de.champonthis.buntspecht.controller.validation.TurnoverValidator; +import de.champonthis.buntspecht.model.Turnover; +import jakarta.transaction.Transactional; + +@RestController +@RequestMapping("/turnovers/manage") +public class TurnoverManagementController extends BaseController { + + @Autowired + private TurnoverManager turnoverManager; + @Autowired + private TurnoverValidator turnoverValidator; + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping + @Transactional + public QueryResults fetch( + @RequestParam("username") Optional usernameParameter, + @RequestParam("limit") Optional limitParameter, + @RequestParam("offset") Optional offsetParameter, + @RequestParam("sort") Optional sort, + @RequestParam("descending") Optional descending, + @RequestParam("from") Optional from, + @RequestParam("to") Optional to, + @RequestParam("created_from") Optional fromCreated, + @RequestParam("created_to") Optional toCreated, + @RequestParam("customer") Optional customer, + @RequestParam("motif") Optional motif) { + + TurnoverFilterModel filter = new TurnoverFilterModel(); + filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null))); + filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null))); + filter.setCustomer(customer.orElse(null)); + filter.setMotif(motif.orElse(null)); + + return turnoverManager.fetch(usernameParameter.orElse(null), limitParameter.orElse(15L), + offsetParameter.orElse(0L), sort.orElse("dueDate"), descending.orElse(false), filter); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/overview") + @Transactional + public QueryResults overview( + @RequestParam("username") Optional usernameParameter, + @RequestParam("limit") Optional limitParameter, + @RequestParam("offset") Optional offsetParameter, + @RequestParam("sort") Optional sort, + @RequestParam("descending") Optional descending, + @RequestParam("from") Optional from, + @RequestParam("to") Optional to, + @RequestParam("created_from") Optional fromCreated, + @RequestParam("created_to") Optional toCreated, + @RequestParam("customer") Optional customer, + @RequestParam("motif") Optional motif) { + + TurnoverFilterModel filter = new TurnoverFilterModel(); + filter.setDueDate(new MinMax(from.orElse(null), to.orElse(null))); + filter.setCreated(new MinMax(fromCreated.orElse(null), toCreated.orElse(null))); + filter.setCustomer(customer.orElse(null)); + filter.setMotif(motif.orElse(null)); + return turnoverManager.overview(usernameParameter.orElse(null), limitParameter.orElse(15L), + offsetParameter.orElse(0L), sort.orElse("username"), + descending.orElse(false), filter); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/{id}") + @Transactional + public Turnover getById(@PathVariable("id") Long id) { + if (!turnoverManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.NO_CONTENT); + } + + return turnoverManager.get(id); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PatchMapping + @Transactional + public Turnover update(@RequestBody Turnover turnover) { + Errors errors = new RequestBodyErrors(turnover); + turnoverValidator.validate(turnover, errors); + + if (errors.hasErrors()) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + Turnover existing = turnoverManager.get(turnover.getId()); + if (existing.equals(turnover)) { + throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED); + } + + if (turnover.getDueDate() == null) { + turnover.setDueDate(turnover.getCreated()); + } + + turnover.setUpdated(Instant.now()); + + return turnoverManager.save(turnover); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/{id}") + @Transactional + public void deleteById(@PathVariable("id") Long id) { + if (!turnoverManager.exists(id)) { + throw new EntityResponseStatusException(HttpStatus.NO_CONTENT); + } + + turnoverManager.deleteById(id); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/UserManagementController.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/UserManagementController.java new file mode 100644 index 0000000..425596a --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/UserManagementController.java @@ -0,0 +1,148 @@ +package de.champonthis.buntspecht.controller.admin; + +import java.util.List; +import java.util.Optional; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.Errors; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.querydsl.core.QueryResults; + +import de.champonthis.buntspecht.businesslogic.UserManager; +import de.champonthis.buntspecht.controller.BaseController; +import de.champonthis.buntspecht.controller.admin.validation.UserValidator; +import de.champonthis.buntspecht.controller.model.UserPasswordModel; +import de.champonthis.buntspecht.controller.support.EntityResponseStatusException; +import de.champonthis.buntspecht.controller.support.RequestBodyErrors; +import de.champonthis.buntspecht.controller.validation.PasswordModelValidator; +import de.champonthis.buntspecht.model.User; +import jakarta.transaction.Transactional; + +@RestController +@RequestMapping("/users/manage") +public class UserManagementController extends BaseController { + + @Autowired + private UserManager userManager; + @Autowired + private UserValidator userValidator; + @Autowired + private PasswordModelValidator passwordModelValidator; + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping + @Transactional + public QueryResults fetch( + @RequestParam("limit") Optional limitParameter, + @RequestParam("offset") Optional offsetParameter, + @RequestParam("sort") Optional sort, + @RequestParam("descending") Optional descending, + @RequestParam("filter") Optional search) { + return userManager.fetch(limitParameter.orElse(15L), offsetParameter.orElse(0L), sort.orElse("username"), + descending.orElse(false), search.orElse("")); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/pick") + @Transactional + public List pick( + @RequestParam("filter") Optional search) { + return userManager.fetch(5L, 0L, "", false, search.orElse("")).getResults(); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @GetMapping("/{username}") + @Transactional + public User get(@PathVariable("username") String username) { + User user = userManager.getByUsername(username); + + if (user == null) { + throw new EntityResponseStatusException(HttpStatus.NO_CONTENT); + } + + return user; + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping + @Transactional + public User create(@RequestBody User user) { + Errors errors = new RequestBodyErrors(user); + userValidator.validateNew(user, errors); + + if (errors.hasErrors()) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + if (user.getLocale() == null) { + user.setLocale("de"); + } + + return userManager.save(user); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PatchMapping + @Transactional + public User update(@RequestBody User user) { + Errors errors = new RequestBodyErrors(user); + userValidator.validate(user, errors); + + if (errors.hasErrors()) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + if (getCurrentUsername().equals(user.getUsername()) && user.getRoles().indexOf("ROLE_ADMIN") == -1) { + user.getRoles().add("ROLE_ADMIN"); + } + + return userManager.save(user); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/{username}") + @Transactional + public void delete(@PathVariable("username") String username) { + User user = userManager.getByUsername(username); + + if (user == null || getCurrentUsername().equals(username)) { + throw new EntityResponseStatusException(HttpStatus.NOT_MODIFIED); + } + + userManager.delete(user); + } + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/{username}/password") + public void password(@PathVariable("username") String username, + @RequestBody UserPasswordModel passwordModel) { + + Errors errors = new RequestBodyErrors(passwordModel); + + User user = userManager.getByUsername(username); + + if (user == null) { + throw new EntityResponseStatusException(HttpStatus.NO_CONTENT); + } + + passwordModelValidator.validate(passwordModel, errors); + + if (errors.hasErrors()) { + throw new EntityResponseStatusException(errors.getAllErrors(), HttpStatus.CONFLICT); + } + + userManager.setPassword(user.getUsername(), passwordModel.getPassword()); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/admin/validation/UserValidator.java b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/validation/UserValidator.java new file mode 100644 index 0000000..c308ebf --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/admin/validation/UserValidator.java @@ -0,0 +1,46 @@ +package de.champonthis.buntspecht.controller.admin.validation; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import de.champonthis.buntspecht.businesslogic.UserManager; +import de.champonthis.buntspecht.model.User; + +@Component +public class UserValidator implements Validator { + + @Autowired + private UserManager userManager; + + @Override + public boolean supports(Class clazz) { + return clazz.isAssignableFrom(User.class); + } + + @Override + public void validate(Object target, Errors errors) { + User user = (User) target; + if (!StringUtils.hasText(user.getUsername())) { + errors.rejectValue("username", "REQUIRED"); + return; + } + } + + public void validateNew(Object target, Errors errors) { + validate(target, errors); + + if (errors.hasErrors()) { + return; + } + + User user = (User) target; + if (userManager.exists(user.getUsername())) { + errors.rejectValue("username", "ALREADY_EXISTS"); + return; + } + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/model/TupleSerializer.java b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TupleSerializer.java new file mode 100644 index 0000000..bee2d6f --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TupleSerializer.java @@ -0,0 +1,28 @@ +package de.champonthis.buntspecht.controller.model; + +import java.io.IOException; + +import org.springframework.boot.jackson.JsonComponent; + +import com.fasterxml.jackson.core.JsonGenerator; +import com.fasterxml.jackson.databind.JsonSerializer; +import com.fasterxml.jackson.databind.SerializerProvider; +import com.querydsl.core.Tuple; + +@JsonComponent +public class TupleSerializer extends JsonSerializer { + + @Override + public void serialize(Tuple value, JsonGenerator gen, SerializerProvider serializers) throws IOException { + if (value.toArray().length > 1) { + gen.writeStartArray(); + } + for (Object object : value.toArray()) { + gen.writeObject(object); + } + if (value.toArray().length > 1) { + gen.writeEndArray(); + } + } + +} \ No newline at end of file diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/model/TurnoverFilterModel.java b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TurnoverFilterModel.java new file mode 100644 index 0000000..accabd2 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/model/TurnoverFilterModel.java @@ -0,0 +1,98 @@ +package de.champonthis.buntspecht.controller.model; + +import java.time.Instant; + +public class TurnoverFilterModel { + + private MinMax created; + private MinMax dueDate; + private MinMax updated; + private String customer; + private String motif; + private MinMax price; + private MinMax timeInvestment; + + public MinMax getCreated() { + return created; + } + + public void setCreated(MinMax created) { + this.created = created; + } + + public MinMax getDueDate() { + return dueDate; + } + + public void setDueDate(MinMax dueDate) { + this.dueDate = dueDate; + } + + public MinMax getUpdated() { + return updated; + } + + public void setUpdated(MinMax updated) { + this.updated = updated; + } + + public String getCustomer() { + return customer; + } + + public void setCustomer(String customer) { + this.customer = customer; + } + + public String getMotif() { + return motif; + } + + public void setMotif(String motif) { + this.motif = motif; + } + + public MinMax getPrice() { + return price; + } + + public void setPrice(MinMax price) { + this.price = price; + } + + public MinMax getTimeInvestment() { + return timeInvestment; + } + + public void setTimeInvestment(MinMax timeInvestment) { + this.timeInvestment = timeInvestment; + } + + public static class MinMax { + + private T min; + private T max; + + public MinMax(T min, T max) { + this.min = min; + this.max = max; + } + + public T getMin() { + return min; + } + + public void setMin(T min) { + this.min = min; + } + + public T getMax() { + return max; + } + + public void setMax(T max) { + this.max = max; + } + + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/model/UserPasswordModel.java b/backend/src/main/java/de/champonthis/buntspecht/controller/model/UserPasswordModel.java new file mode 100644 index 0000000..cd27fc7 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/model/UserPasswordModel.java @@ -0,0 +1,32 @@ +package de.champonthis.buntspecht.controller.model; + +public class UserPasswordModel { + + private String old; + private String password; + private String password2; + + public String getOld() { + return old; + } + + public void setOld(String old) { + this.old = old; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getPassword2() { + return password2; + } + + public void setPassword2(String password2) { + this.password2 = password2; + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/support/ControllerExceptionHandler.java b/backend/src/main/java/de/champonthis/buntspecht/controller/support/ControllerExceptionHandler.java new file mode 100644 index 0000000..d81ed18 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/support/ControllerExceptionHandler.java @@ -0,0 +1,23 @@ +package de.champonthis.buntspecht.controller.support; + +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.context.request.WebRequest; +import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler; + + +@ControllerAdvice +public class ControllerExceptionHandler extends ResponseEntityExceptionHandler { + + + @ExceptionHandler(value = { EntityResponseStatusException.class }) + protected ResponseEntity handleResponseEntityStatusException(RuntimeException exception, + WebRequest request) { + EntityResponseStatusException entityResponseStatusException = (EntityResponseStatusException) exception; + return handleExceptionInternal(exception, entityResponseStatusException.getBody(), new HttpHeaders(), + entityResponseStatusException.getStatus(), request); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/support/EntityResponseStatusException.java b/backend/src/main/java/de/champonthis/buntspecht/controller/support/EntityResponseStatusException.java new file mode 100644 index 0000000..f5b0696 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/support/EntityResponseStatusException.java @@ -0,0 +1,56 @@ +package de.champonthis.buntspecht.controller.support; + +import org.springframework.core.NestedRuntimeException; +import org.springframework.http.HttpStatus; +import org.springframework.util.Assert; + +import jakarta.annotation.Nullable; + + +public class EntityResponseStatusException extends NestedRuntimeException { + + private static final long serialVersionUID = 1L; + + private final HttpStatus status; + + @Nullable + private final Object body; + + + public EntityResponseStatusException(HttpStatus status) { + this(null, status); + } + + + public EntityResponseStatusException(@Nullable Object body, HttpStatus status) { + this(body, status, null); + } + + + public EntityResponseStatusException(@Nullable Object body, HttpStatus status, @Nullable Throwable cause) { + super(null, cause); + Assert.notNull(status, "HttpStatus is required"); + this.status = status; + this.body = body; + } + + + public HttpStatus getStatus() { + return this.status; + } + + + @Nullable + public Object getBody() { + return this.body; + } + + /* + * @see org.springframework.core.NestedRuntimeException#getMessage() + */ + @Override + public String getMessage() { + return this.status + (this.body != null ? " \"" + this.body + "\"" : ""); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/support/JsonStringBodyControllerAdvice.java b/backend/src/main/java/de/champonthis/buntspecht/controller/support/JsonStringBodyControllerAdvice.java new file mode 100644 index 0000000..4ec5570 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/support/JsonStringBodyControllerAdvice.java @@ -0,0 +1,101 @@ +package de.champonthis.buntspecht.controller.support; + +import java.io.IOException; +import java.lang.reflect.Type; + +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpInputMessage; +import org.springframework.http.MediaType; +import org.springframework.http.converter.HttpMessageConverter; +import org.springframework.http.converter.StringHttpMessageConverter; +import org.springframework.http.server.ServerHttpRequest; +import org.springframework.http.server.ServerHttpResponse; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice; +import org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice; + +import com.google.gson.Gson; +import com.google.gson.JsonPrimitive; + + +@ControllerAdvice +public class JsonStringBodyControllerAdvice implements RequestBodyAdvice, ResponseBodyAdvice { + + private Gson gson = new Gson(); + + /* + * @see org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice# + * supports(org.springframework.core.MethodParameter, java.lang.reflect.Type, + * java.lang.Class) + */ + @Override + public boolean supports(MethodParameter methodParameter, Type targetType, + Class> converterType) { + return targetType instanceof Class && String.class.equals((Class) targetType); + } + + /* + * @see org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice# + * beforeBodyRead(org.springframework.http.HttpInputMessage, + * org.springframework.core.MethodParameter, java.lang.reflect.Type, + * java.lang.Class) + */ + @Override + public HttpInputMessage beforeBodyRead(HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, + Class> converterType) throws IOException { + return inputMessage; + } + + /* + * @see org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice# + * afterBodyRead(java.lang.Object, org.springframework.http.HttpInputMessage, + * org.springframework.core.MethodParameter, java.lang.reflect.Type, + * java.lang.Class) + */ + @Override + public Object afterBodyRead(Object body, HttpInputMessage inputMessage, MethodParameter parameter, Type targetType, + Class> converterType) { + body = ((String) body).replaceAll("^\"|\"$", ""); + return body; + } + + /* + * @see org.springframework.web.servlet.mvc.method.annotation.RequestBodyAdvice# + * handleEmptyBody(java.lang.Object, org.springframework.http.HttpInputMessage, + * org.springframework.core.MethodParameter, java.lang.reflect.Type, + * java.lang.Class) + */ + @Override + public Object handleEmptyBody(Object body, HttpInputMessage inputMessage, MethodParameter parameter, + Type targetType, Class> converterType) { + return body; + } + + /* + * @see + * org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice# + * supports(org.springframework.core.MethodParameter, java.lang.Class) + */ + @Override + public boolean supports(MethodParameter returnType, Class> converterType) { + return converterType == StringHttpMessageConverter.class; + } + + /* + * @see + * org.springframework.web.servlet.mvc.method.annotation.ResponseBodyAdvice# + * beforeBodyWrite(java.lang.Object, org.springframework.core.MethodParameter, + * org.springframework.http.MediaType, java.lang.Class, + * org.springframework.http.server.ServerHttpRequest, + * org.springframework.http.server.ServerHttpResponse) + */ + @Override + public String beforeBodyWrite(String body, MethodParameter returnType, MediaType selectedContentType, + Class> selectedConverterType, ServerHttpRequest request, + ServerHttpResponse response) { + response.getHeaders().set(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE); + return gson.toJson(new JsonPrimitive(body)); + } + +} \ No newline at end of file diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/support/RequestBodyErrors.java b/backend/src/main/java/de/champonthis/buntspecht/controller/support/RequestBodyErrors.java new file mode 100644 index 0000000..f1dd0b5 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/support/RequestBodyErrors.java @@ -0,0 +1,37 @@ +package de.champonthis.buntspecht.controller.support; + +import org.springframework.lang.Nullable; +import org.springframework.validation.AbstractBindingResult; + + +public class RequestBodyErrors extends AbstractBindingResult { + + @Nullable + private final Object target; + + + public RequestBodyErrors(@Nullable Object target) { + super("request-body"); + this.target = target; + } + + /* + * @see org.springframework.validation.AbstractBindingResult#getTarget() + */ + @Override + public Object getTarget() { + return target; + } + + /* + * @see + * org.springframework.validation.AbstractBindingResult#getActualFieldValue(java + * .lang.String) + */ + @Override + protected Object getActualFieldValue(String field) { + // Not necessary + return null; + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/validation/PasswordModelValidator.java b/backend/src/main/java/de/champonthis/buntspecht/controller/validation/PasswordModelValidator.java new file mode 100644 index 0000000..03f2b10 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/validation/PasswordModelValidator.java @@ -0,0 +1,85 @@ +package de.champonthis.buntspecht.controller.validation; + +import java.util.ArrayList; +import java.util.List; + +import org.passay.CharacterRule; +import org.passay.EnglishCharacterData; +import org.passay.LengthRule; +import org.passay.PasswordData; +import org.passay.PasswordValidator; +import org.passay.Rule; +import org.passay.RuleResult; +import org.passay.RuleResultDetail; +import org.passay.WhitespaceRule; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Component; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import de.champonthis.buntspecht.businesslogic.SystemPropertyManager; +import de.champonthis.buntspecht.controller.model.UserPasswordModel; + +@Component +public class PasswordModelValidator implements Validator { + + @Autowired + private SystemPropertyManager systemPropertyManager; + + public static final String SYSTEM_PROPERTY_PASSWORD_RULE_WHITESPACE = "password.rule.whitespace"; + public static final String SYSTEM_PROPERTY_PASSWORD_RULE_LENGTH = "password.rule.length"; + public static final String SYSTEM_PROPERTY_PASSWORD_RULE_UPPERCASE = "password.rule.uppercase"; + public static final String SYSTEM_PROPERTY_PASSWORD_RULE_DIGIT = "password.rule.digit"; + public static final String SYSTEM_PROPERTY_PASSWORD_RULE_SPECIAL = "password.rule.special"; + + @Override + public boolean supports(Class clazz) { + return clazz.isAssignableFrom(UserPasswordModel.class); + } + + @Override + public void validate(Object target, Errors errors) { + UserPasswordModel passwordModel = (UserPasswordModel) target; + + List rules = new ArrayList(); + + if (systemPropertyManager.getBoolean(SYSTEM_PROPERTY_PASSWORD_RULE_WHITESPACE, true)) { + rules.add(new WhitespaceRule()); + } + + int length = systemPropertyManager.getInteger(SYSTEM_PROPERTY_PASSWORD_RULE_LENGTH, 8); + if (length > 0) { + rules.add(new LengthRule(length, 4096)); + } + + int uppercase = systemPropertyManager.getInteger(SYSTEM_PROPERTY_PASSWORD_RULE_UPPERCASE, 1); + if (uppercase > 0) { + rules.add(new CharacterRule(EnglishCharacterData.UpperCase, uppercase)); + } + + int digit = systemPropertyManager.getInteger(SYSTEM_PROPERTY_PASSWORD_RULE_DIGIT, 1); + if (digit > 0) { + rules.add(new CharacterRule(EnglishCharacterData.Digit, digit)); + } + + int special = systemPropertyManager.getInteger(SYSTEM_PROPERTY_PASSWORD_RULE_SPECIAL, 1); + if (special > 0) { + rules.add(new CharacterRule(EnglishCharacterData.Special, special)); + } + + PasswordValidator validator = new PasswordValidator(rules); + PasswordData password = new PasswordData(passwordModel.getPassword()); + RuleResult result = validator.validate(password); + + if (!result.isValid()) { + for (RuleResultDetail ruleResultDetail : result.getDetails()) { + errors.rejectValue("password", ruleResultDetail.getErrorCode()); + } + } + + if (!passwordModel.getPassword().equals(passwordModel.getPassword2())) { + errors.rejectValue("password2", "NOT_MATCH"); + } + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/controller/validation/TurnoverValidator.java b/backend/src/main/java/de/champonthis/buntspecht/controller/validation/TurnoverValidator.java new file mode 100644 index 0000000..4a8bde8 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/controller/validation/TurnoverValidator.java @@ -0,0 +1,38 @@ +package de.champonthis.buntspecht.controller.validation; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.validation.Errors; +import org.springframework.validation.Validator; + +import de.champonthis.buntspecht.model.Turnover; + +@Component +public class TurnoverValidator implements Validator { + + @Override + public boolean supports(Class clazz) { + return clazz.isAssignableFrom(Turnover.class); + } + + @Override + public void validate(Object target, Errors errors) { + Turnover turnover = (Turnover) target; + + if (!StringUtils.hasText(turnover.getCustomer())) { + errors.rejectValue("customer", "REQUIRED"); + } + + if (!StringUtils.hasText(turnover.getMotif())) { + errors.rejectValue("motif", "REQUIRED"); + } + + if (turnover.getPrice() == 0) { + errors.rejectValue("price", "REQUIRED"); + } + + if (turnover.getPrice() < 0) { + errors.rejectValue("price", "POSITIVE_VALUE"); + } + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/i18n/businesslogic/I18nManager.java b/backend/src/main/java/de/champonthis/buntspecht/i18n/businesslogic/I18nManager.java new file mode 100644 index 0000000..33a4c2f --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/i18n/businesslogic/I18nManager.java @@ -0,0 +1,143 @@ +package de.champonthis.buntspecht.i18n.businesslogic; + +import java.io.File; +import java.io.FileReader; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map.Entry; +import java.util.stream.Collectors; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.SmartInitializingSingleton; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.io.Resource; +import org.springframework.core.io.ResourceLoader; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import de.champonthis.buntspecht.i18n.model.I18n; +import de.champonthis.buntspecht.i18n.repository.I18nRepository; + + +@Component +public class I18nManager implements SmartInitializingSingleton { + + private Logger logger = LoggerFactory.getLogger(I18nManager.class); + + @Autowired + private I18nRepository i18nRepository; + + @Autowired + private ResourceLoader resourceLoader; + + private Gson gson = new Gson(); + + + public I18n get(String locale) { + return i18nRepository.findById(locale).orElse(null); + } + + + public JsonObject getLabel(String locale) { + I18n i18n = get(locale); + if (i18n != null && StringUtils.hasText(i18n.getLabel())) { + JsonElement element = JsonParser.parseString(i18n.getLabel()); + if (element != null && element.isJsonObject()) { + return element.getAsJsonObject(); + } + } + + return null; + } + + + public List getLocales() { + return i18nRepository.findAll().stream().map(I18n::getLocale).collect(Collectors.toList()); + } + + + protected void extendJsonObject(JsonObject dest, JsonObject src) { + for (Entry srcEntry : src.entrySet()) { + String srcKey = srcEntry.getKey(); + JsonElement srcValue = srcEntry.getValue(); + if (dest.has(srcKey)) { + JsonElement destValue = dest.get(srcKey); + if (destValue.isJsonObject() && srcValue.isJsonObject()) { + extendJsonObject(destValue.getAsJsonObject(), srcValue.getAsJsonObject()); + } else { + dest.add(srcKey, srcValue); + } + } else { + dest.add(srcKey, srcValue); + } + } + } + + + public I18n addLabel(String locale, JsonObject newLabel) { + JsonObject label = getLabel(locale); + + if (label == null || label.size() == 0 || label.entrySet().isEmpty()) { + label = newLabel; + } else { + extendJsonObject(label, newLabel); + } + + I18n i18n = new I18n(); + i18n.setLocale(locale); + i18n.setLabel(gson.toJson(label)); + + return i18nRepository.save(i18n); + } + + + public I18n setLabel(String locale, JsonObject label) { + I18n i18n = new I18n(); + i18n.setLocale(locale); + i18n.setLabel(gson.toJson(label)); + + return i18nRepository.save(i18n); + } + + + public void deleteLabel(String locale) { + if (i18nRepository.existsById(locale)) { + i18nRepository.deleteById(locale); + } + } + + /* + * @see org.springframework.beans.factory.SmartInitializingSingleton# + * afterSingletonsInstantiated() + */ + @Override + public void afterSingletonsInstantiated() { + try { + Resource resource = resourceLoader.getResource("classpath:label"); + + if (resource.exists()) { + File labelFolder = resource.getFile(); + if (labelFolder.exists() && labelFolder.isDirectory()) { + for (File labelFile : labelFolder.listFiles()) { + JsonObject label = JsonParser.parseReader(new FileReader(labelFile, StandardCharsets.UTF_8)) + .getAsJsonObject(); + + String locale = labelFile.getName().replace(".json", ""); + addLabel(locale, label); + } + } + } + + } catch (IOException e) { + logger.warn("cannot read in label folder", e.getMessage()); + } + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/i18n/controller/I18nController.java b/backend/src/main/java/de/champonthis/buntspecht/i18n/controller/I18nController.java new file mode 100644 index 0000000..21eba4d --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/i18n/controller/I18nController.java @@ -0,0 +1,83 @@ +package de.champonthis.buntspecht.i18n.controller; + +import java.io.IOException; +import java.util.List; + +import jakarta.servlet.http.HttpServletResponse; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.google.gson.Gson; +import com.google.gson.JsonElement; +import com.google.gson.JsonIOException; +import com.google.gson.JsonObject; + +import de.champonthis.buntspecht.controller.BaseController; +import de.champonthis.buntspecht.i18n.businesslogic.I18nManager; + + +@RestController +@RequestMapping("/i18n") +public class I18nController extends BaseController { + + @Autowired + private I18nManager i18nManager; + + private Gson gson = new Gson(); + + + @GetMapping + public List getLocales() { + return i18nManager.getLocales(); + } + + + @GetMapping("/{locale}") + public void getLabel(@PathVariable("locale") String locale, HttpServletResponse response) + throws JsonIOException, IOException { + JsonObject label = i18nManager.getLabel(locale); + if (label != null) { + response.setCharacterEncoding("utf-8"); + gson.toJson(label, response.getWriter()); + } + } + + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PostMapping("/{locale}") + public void setLabel(@PathVariable("locale") String locale, @RequestBody Object label) { + JsonElement element = gson.toJsonTree(label); + + if (element != null && element.isJsonObject()) { + i18nManager.setLabel(locale, element.getAsJsonObject()); + } + } + + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @PutMapping("/{locale}") + public void addLabel(@PathVariable("locale") String locale, @RequestBody Object label) { + JsonElement element = gson.toJsonTree(label); + + if (element != null && element.isJsonObject()) { + i18nManager.addLabel(locale, element.getAsJsonObject()); + } + } + + + @PreAuthorize("hasRole('ROLE_ADMIN')") + @DeleteMapping("/{locale}") + public void deleteLocale(@PathVariable("locale") String locale) { + i18nManager.deleteLabel(locale); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/i18n/model/I18n.java b/backend/src/main/java/de/champonthis/buntspecht/i18n/model/I18n.java new file mode 100644 index 0000000..22c3199 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/i18n/model/I18n.java @@ -0,0 +1,43 @@ +package de.champonthis.buntspecht.i18n.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; + + +@Entity +@Table(name = "i18n", uniqueConstraints = @UniqueConstraint(columnNames = { "locale" })) +public class I18n { + + @Id + @Column(name = "locale", unique = true, nullable = false) + private String locale; + + @Lob + @Column(name = "label", length = 100000) + private String label; + + + public String getLocale() { + return locale; + } + + + public void setLocale(String locale) { + this.locale = locale; + } + + + public String getLabel() { + return label; + } + + + public void setLabel(String label) { + this.label = label; + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/i18n/repository/I18nRepository.java b/backend/src/main/java/de/champonthis/buntspecht/i18n/repository/I18nRepository.java new file mode 100644 index 0000000..f4d34ed --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/i18n/repository/I18nRepository.java @@ -0,0 +1,13 @@ +package de.champonthis.buntspecht.i18n.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.champonthis.buntspecht.i18n.model.I18n; + + +@Repository +public interface I18nRepository extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/model/PersistentLogin.java b/backend/src/main/java/de/champonthis/buntspecht/model/PersistentLogin.java new file mode 100644 index 0000000..01a8dfc --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/model/PersistentLogin.java @@ -0,0 +1,56 @@ +package de.champonthis.buntspecht.model; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "persistent_logins") +public class PersistentLogin { + + @Column(name = "username", length = 64, nullable = false) + private String username; + @Id + @Column(name = "series", length = 64) + private String series; + @Column(name = "token", length = 64, nullable = false) + private String token; + @Column(name = "last_used", nullable = false) + private Instant last_used; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getSeries() { + return series; + } + + public void setSeries(String series) { + this.series = series; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public Instant getLast_used() { + return last_used; + } + + public void setLast_used(Instant last_used) { + this.last_used = last_used; + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/model/SystemProperty.java b/backend/src/main/java/de/champonthis/buntspecht/model/SystemProperty.java new file mode 100755 index 0000000..953042a --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/model/SystemProperty.java @@ -0,0 +1,53 @@ +package de.champonthis.buntspecht.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; + + +@Entity +@Table(name = "system_properties") +public class SystemProperty { + + @Id + @Column(name = "id") + private String key; + @Lob + @Column(name = "value", length = 100000) + private String value; + + + public SystemProperty() { + super(); + } + + + public SystemProperty(String key, String value) { + super(); + this.key = key; + this.value = value; + } + + + public String getKey() { + return key; + } + + + public void setKey(String key) { + this.key = key; + } + + + public String getValue() { + return value; + } + + + public void setValue(String value) { + this.value = value; + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/model/Turnover.java b/backend/src/main/java/de/champonthis/buntspecht/model/Turnover.java new file mode 100644 index 0000000..4367871 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/model/Turnover.java @@ -0,0 +1,171 @@ +package de.champonthis.buntspecht.model; + +import java.time.Instant; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; + +@Entity +@Table(name = "turnovers") +public class Turnover { + + @Id + @Column(name = "id", updatable = false, unique = true, nullable = false) + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "username", nullable = false) + private String username; + + @Column(name = "created", nullable = false, updatable = false) + private Instant created; + + @Column(name = "due_date", nullable = false) + private Instant dueDate; + + @Column(name = "updated", nullable = false) + private Instant updated; + + @Column(name = "customer", nullable = false) + private String customer; + + @Column(name = "motif", nullable = false) + private String motif; + + @Column(name = "price", nullable = false) + private float price; + + @Column(name = "time_investment", nullable = true) + private float timeInvestment; + + @Lob + @Column(name = "remark", nullable = true, length = 5000) + private String remark; + + @Lob + @Column(name = "material_consumption", nullable = true, length = 5000) + private String materialConsumption; + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public Instant getCreated() { + return created; + } + + public void setCreated(Instant created) { + this.created = created; + } + + public Instant getDueDate() { + return dueDate; + } + + public void setDueDate(Instant dueDate) { + this.dueDate = dueDate; + } + + public Instant getUpdated() { + return updated; + } + + public void setUpdated(Instant updated) { + this.updated = updated; + } + + public String getCustomer() { + return customer; + } + + public void setCustomer(String customer) { + this.customer = customer; + } + + public String getMotif() { + return motif; + } + + public void setMotif(String motif) { + this.motif = motif; + } + + public float getPrice() { + return price; + } + + public void setPrice(float price) { + this.price = price; + } + + public float getTimeInvestment() { + return timeInvestment; + } + + public void setTimeInvestment(float timeInvestment) { + this.timeInvestment = timeInvestment; + } + + public String getRemark() { + return remark; + } + + public void setRemark(String remark) { + this.remark = remark; + } + + public String getMaterialConsumption() { + return materialConsumption; + } + + public void setMaterialConsumption(String materialConsumption) { + this.materialConsumption = materialConsumption; + } + + @Override + public boolean equals(Object obj) { + if (!(obj instanceof Turnover)) { + return false; + } + Turnover turnover = (Turnover) obj; + boolean equals = true; + + equals &= dueDate.equals(turnover.getDueDate()); + + equals &= id == null && turnover.getId() == null || id.equals(turnover.getId()); + + equals &= username == null && turnover.getUsername() == null || username.equals(turnover.getUsername()); + + equals &= customer == null && turnover.getCustomer() == null || customer.equals(turnover.getCustomer()); + + equals &= motif == null && turnover.getMotif() == null || motif.equals(turnover.getMotif()); + + equals &= price == turnover.getPrice(); + + equals &= timeInvestment == turnover.getTimeInvestment(); + + equals &= remark == null && turnover.getRemark() == null || remark.equals(turnover.getRemark()); + + equals &= materialConsumption == null && turnover.getMaterialConsumption() == null + || materialConsumption.equals(turnover.getMaterialConsumption()); + + return equals; + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/model/User.java b/backend/src/main/java/de/champonthis/buntspecht/model/User.java new file mode 100644 index 0000000..124d5fc --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/model/User.java @@ -0,0 +1,133 @@ +package de.champonthis.buntspecht.model; + +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonInclude.Include; + +import jakarta.persistence.CollectionTable; +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.Id; +import jakarta.persistence.Lob; +import jakarta.persistence.Table; +import jakarta.persistence.Transient; + +@Entity +@Table(name = "users") +@JsonInclude(Include.NON_EMPTY) +public class User { + + @Id + @Column(name = "username", nullable = false) + private String username; + @Column(name = "external_id", nullable = true) + private String externalId; + @Column(name = "name", nullable = true) + private String name; + @JsonIgnore + @Column(name = "password_hash", nullable = true) + private String passwordHash; + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "users_roles") + private List roles; + @Lob + @Column(name = "about", nullable = true, length = 100000) + private String about; + @Column(name = "email", nullable = true) + private String email; + @Column(name = "locale", nullable = true, columnDefinition = "varchar(255) default 'de'") + private String locale; + @Column(name = "dark_theme", nullable = true, columnDefinition = "boolean default false") + private boolean darkTheme; + @Transient + private Map metadata; + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getExternalId() { + return externalId; + } + + public void setExternalId(String externalId) { + this.externalId = externalId; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getPasswordHash() { + return passwordHash; + } + + public void setPasswordHash(String passwordHash) { + this.passwordHash = passwordHash; + } + + public List getRoles() { + return roles; + } + + public void setRoles(List roles) { + this.roles = roles; + } + + public String getAbout() { + return about; + } + + public void setAbout(String about) { + this.about = about; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public String getLocale() { + return locale; + } + + public void setLocale(String locale) { + this.locale = locale; + } + + public boolean isDarkTheme() { + return darkTheme; + } + + public void setDarkTheme(boolean darkTheme) { + this.darkTheme = darkTheme; + } + + public Map getMetadata() { + if (metadata == null) { + metadata = Map.of(); + } + return metadata; + } + + public void setMetadata(Map metadata) { + this.metadata = metadata; + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/repository/SystemPropertyRepository.java b/backend/src/main/java/de/champonthis/buntspecht/repository/SystemPropertyRepository.java new file mode 100755 index 0000000..e08a347 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/repository/SystemPropertyRepository.java @@ -0,0 +1,14 @@ +package de.champonthis.buntspecht.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.champonthis.buntspecht.model.SystemProperty; + + +@Repository +public interface SystemPropertyRepository + extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/repository/TurnoverRepository.java b/backend/src/main/java/de/champonthis/buntspecht/repository/TurnoverRepository.java new file mode 100644 index 0000000..5f1872b --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/repository/TurnoverRepository.java @@ -0,0 +1,12 @@ +package de.champonthis.buntspecht.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.champonthis.buntspecht.model.Turnover; + +@Repository +public interface TurnoverRepository extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/repository/UserRepository.java b/backend/src/main/java/de/champonthis/buntspecht/repository/UserRepository.java new file mode 100644 index 0000000..f038550 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/repository/UserRepository.java @@ -0,0 +1,13 @@ +package de.champonthis.buntspecht.repository; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.querydsl.QuerydslPredicateExecutor; +import org.springframework.stereotype.Repository; + +import de.champonthis.buntspecht.model.User; + + +@Repository +public interface UserRepository extends JpaRepository, QuerydslPredicateExecutor { + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/security/LocalRememberMeServices.java b/backend/src/main/java/de/champonthis/buntspecht/security/LocalRememberMeServices.java new file mode 100644 index 0000000..f7ff699 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/security/LocalRememberMeServices.java @@ -0,0 +1,37 @@ +package de.champonthis.buntspecht.security; + +import jakarta.servlet.http.HttpServletRequest; + +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; + + +public class LocalRememberMeServices extends PersistentTokenBasedRememberMeServices { + + + public LocalRememberMeServices(String key, UserDetailsService userDetailsService, + PersistentTokenRepository tokenRepository) { + super(key, userDetailsService, tokenRepository); + } + + /* + * @see org.springframework.security.web.authentication.rememberme. + * AbstractRememberMeServices#rememberMeRequested(javax.servlet.http. + * HttpServletRequest, java.lang.String) + */ + @Override + protected boolean rememberMeRequested(HttpServletRequest request, String parameter) { + Object value = request.getAttribute(parameter); + if (value != null) { + String paramValue = value.toString(); + if (paramValue.equalsIgnoreCase("true") || paramValue.equalsIgnoreCase("on") + || paramValue.equalsIgnoreCase("yes") || paramValue.equals("1")) { + return true; + } + } + + return super.rememberMeRequested(request, parameter); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/security/LocalUserDetails.java b/backend/src/main/java/de/champonthis/buntspecht/security/LocalUserDetails.java new file mode 100644 index 0000000..39f555d --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/security/LocalUserDetails.java @@ -0,0 +1,19 @@ +package de.champonthis.buntspecht.security; + +import java.util.Collection; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; + + +public class LocalUserDetails extends User { + + + private static final long serialVersionUID = 1L; + + + public LocalUserDetails(String username, String password, Collection authorities) { + super(username, password, authorities); + } + +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/security/PasswordEncoderConfig.java b/backend/src/main/java/de/champonthis/buntspecht/security/PasswordEncoderConfig.java new file mode 100644 index 0000000..d2983a0 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/security/PasswordEncoderConfig.java @@ -0,0 +1,15 @@ +package de.champonthis.buntspecht.security; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.argon2.Argon2PasswordEncoder; + +@Configuration +public class PasswordEncoderConfig { + + + @Bean(name = "passwordEncoder") + public Argon2PasswordEncoder passwordEncoder() { + return Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/security/SecurityConfig.java b/backend/src/main/java/de/champonthis/buntspecht/security/SecurityConfig.java new file mode 100755 index 0000000..6a3a499 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/security/SecurityConfig.java @@ -0,0 +1,113 @@ +package de.champonthis.buntspecht.security; + +import java.util.Collections; +import java.util.List; + +import javax.sql.DataSource; + +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.http.HttpStatus; +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.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.authentication.RememberMeServices; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; +import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import de.champonthis.buntspecht.businesslogic.UserManager; +import de.champonthis.buntspecht.security.handler.FormAuthenticationFailureHandler; +import de.champonthis.buntspecht.security.handler.OAuth2AuthenticationSuccessHandler; + +@EnableWebSecurity +@EnableMethodSecurity(prePostEnabled = true) +@Configuration +public class SecurityConfig { + + @Autowired + private UserManager userManager; + @Autowired + private OAuth2AuthenticationSuccessHandler oAuth2AuthenticationSuccessHandler; + @Autowired + private DataSource dataSource; + @Value("${loginUrl:/login}") + private String loginUrl; + @Value("${loginTargetUrl:/}") + private String loginTargetUrl; + @Value("${spring.security.oauth2.client:false}") + private boolean oauth2Enabled; + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + + if (oauth2Enabled) { + oAuth2AuthenticationSuccessHandler.setDefaultTargetUrl(loginTargetUrl); + oAuth2AuthenticationSuccessHandler.setRememberMeServices(rememberMeServices()); + } + + http + // crsf + .csrf((csrf) -> csrf.disable()) + // cors + // .cors().configurationSource(corsConfigurationSource()).and() + // anonymous + .anonymous((anonymous) -> anonymous.disable()) + // login + .formLogin((formLogin) -> formLogin.loginPage("/login").defaultSuccessUrl(loginTargetUrl) + .failureHandler(new FormAuthenticationFailureHandler(loginUrl))) + // remember me + .rememberMe((rememberMe) -> rememberMe.rememberMeServices(rememberMeServices())) + // logout + .logout((logout) -> logout.logoutUrl("/logout") + .logoutSuccessHandler(new HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) + // exception + .exceptionHandling((exceptionHandling) -> exceptionHandling + .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + new AntPathRequestMatcher("/api/**"))); + + if (oauth2Enabled) { + http.oauth2Login((oauth2Login) -> oauth2Login.successHandler(oAuth2AuthenticationSuccessHandler) + .failureHandler(new SimpleUrlAuthenticationFailureHandler(loginUrl + "?externalError")) + .loginPage("/login")); + } + + return http.build(); + } + + @Bean + public PersistentTokenRepository persistentTokenRepository() { + JdbcTokenRepositoryImpl tokenRepository = new JdbcTokenRepositoryImpl(); + tokenRepository.setDataSource(dataSource); + return tokenRepository; + } + + @Bean + public RememberMeServices rememberMeServices() { + PersistentTokenBasedRememberMeServices rememberMeServices = new LocalRememberMeServices("remember-me", + userManager, persistentTokenRepository()); + return rememberMeServices; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(Collections.singletonList("*")); + configuration.setAllowCredentials(true); + configuration.setAllowedHeaders(Collections.singletonList("*")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/security/handler/FormAuthenticationFailureHandler.java b/backend/src/main/java/de/champonthis/buntspecht/security/handler/FormAuthenticationFailureHandler.java new file mode 100644 index 0000000..6fa9268 --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/security/handler/FormAuthenticationFailureHandler.java @@ -0,0 +1,28 @@ +package de.champonthis.buntspecht.security.handler; + +import java.io.IOException; + +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + +public class FormAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler { + + private String failureUrl; + + public FormAuthenticationFailureHandler(String failureUrl) { + super(failureUrl); + this.failureUrl = failureUrl; + } + + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + setDefaultFailureUrl(failureUrl + "?error&username=" + request.getParameter("username")); + super.onAuthenticationFailure(request, response, exception); + setDefaultFailureUrl(failureUrl); + } +} diff --git a/backend/src/main/java/de/champonthis/buntspecht/security/handler/OAuth2AuthenticationSuccessHandler.java b/backend/src/main/java/de/champonthis/buntspecht/security/handler/OAuth2AuthenticationSuccessHandler.java new file mode 100644 index 0000000..8b9fdca --- /dev/null +++ b/backend/src/main/java/de/champonthis/buntspecht/security/handler/OAuth2AuthenticationSuccessHandler.java @@ -0,0 +1,68 @@ +package de.champonthis.buntspecht.security.handler; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.web.authentication.RememberMeServices; +import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import de.champonthis.buntspecht.businesslogic.UserManager; +import de.champonthis.buntspecht.model.User; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + + +@Component +public class OAuth2AuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { + + @Autowired + private UserManager userManager; + + private RememberMeServices rememberMeServices; + + /* + * @see org.springframework.security.web.authentication. + * SavedRequestAwareAuthenticationSuccessHandler#onAuthenticationSuccess(javax. + * servlet.http.HttpServletRequest, javax.servlet.http.HttpServletResponse, + * org.springframework.security.core.Authentication) + */ + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException, ServletException { + User user = userManager.getByAuth(authentication); + + UserDetails userDetails = userManager.loadUserByUsername(user.getUsername()); + + List authorities = new ArrayList<>(); + authorities.addAll(authentication.getAuthorities()); + authorities.addAll(userDetails.getAuthorities()); + + UsernamePasswordAuthenticationToken newAuthentication = new UsernamePasswordAuthenticationToken(userDetails, + null, authorities); + + SecurityContextHolder.getContext().setAuthentication(newAuthentication); + + if (rememberMeServices != null) { + request.setAttribute("remember-me", "true"); + rememberMeServices.loginSuccess(request, response, newAuthentication); + } + + handle(request, response, newAuthentication); + clearAuthenticationAttributes(request); + } + + + public void setRememberMeServices(RememberMeServices rememberMeServices) { + this.rememberMeServices = rememberMeServices; + } + +}