Compare commits

...

9 commits
main ... java

Author SHA1 Message Date
eff486898f More ktor parity work 2023-08-05 20:59:27 -06:00
e84dedf675 fixup! Implement /api/transactions/sum 2023-07-17 21:29:25 -06:00
c97c9b2c67 Implement /api/transactions/sum
This endpoint is only included for legacy support. Ideally, the client would just pull the list of transactions and calculate the sum locally for faster results.
2023-07-17 21:25:38 -06:00
95ca0ec9fc Update categories routes to match Ktor 2023-06-05 11:38:03 -06:00
89b1b6ccc7 Update budgets routes to match Ktor 2023-05-02 21:22:16 -06:00
09ad68a528 Update users routes to match Ktor 2023-04-25 20:02:39 -06:00
6f68065b95 Upgrade to Spring Boot 3
Signed-off-by: William Brawner <me@wbrawner.com>
2023-03-04 09:03:36 -07:00
41466a1134 Bump gradle wrapper version 2023-03-02 20:51:13 -07:00
808945cd78 Change package name to com.wbrawner.twigs 2023-03-02 20:50:17 -07:00
64 changed files with 969 additions and 538 deletions

View file

@ -1,31 +1,18 @@
apply plugin: 'java'
apply plugin: 'application'
apply plugin: "io.spring.dependency-management"
apply plugin: "org.springframework.boot"
repositories {
mavenLocal()
mavenCentral()
maven {
url = "http://repo.maven.apache.org/maven2"
}
plugins {
id 'java'
id 'application'
id "io.spring.dependency-management"
id "org.springframework.boot"
}
dependencies {
implementation project(':core')
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.session:spring-session-jdbc"
implementation "org.springframework.boot:spring-boot-starter-web"
runtimeOnly "mysql:mysql-connector-java:8.0.15"
implementation "org.springframework.boot:spring-boot-starter-security"
testImplementation "org.springframework.boot:spring-boot-starter-test"
testImplementation "org.springframework.security:spring-security-test:5.1.5.RELEASE"
testImplementation "org.springframework.security:spring-security-test"
}
jar {
description = "twigs-server"
}
mainClassName = "com.wbrawner.budgetserver.TwigsServerApplication"
sourceCompatibility = 14
targetCompatibility = 14
sourceCompatibility = 17
targetCompatibility = 17

View file

@ -1,11 +0,0 @@
package com.wbrawner.budgetserver.budget;
public class BudgetBalanceResponse {
public final String id;
public final long balance;
public BudgetBalanceResponse(String id, long balance) {
this.id = id;
this.balance = balance;
}
}

View file

@ -1,6 +0,0 @@
package com.wbrawner.budgetserver.budget;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface BudgetRepository extends PagingAndSortingRepository<Budget, String> {
}

View file

@ -1,10 +0,0 @@
package com.wbrawner.budgetserver.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.method.configuration.GlobalMethodSecurityConfiguration;
@Configuration
@EnableGlobalMethodSecurity(prePostEnabled = true)
public class MethodSecurity extends GlobalMethodSecurityConfiguration {
}

View file

@ -1,118 +0,0 @@
package com.wbrawner.budgetserver.config;
import com.wbrawner.budgetserver.passwordresetrequest.PasswordResetRequestRepository;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.user.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.builders.AuthenticationManagerBuilder;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.annotation.web.configuration.WebSecurityConfigurerAdapter;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.web.cors.CorsConfiguration;
import javax.sql.DataSource;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
private final Environment env;
private final DataSource datasource;
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository;
private final PasswordResetRequestRepository passwordResetRequestRepository;
private final JdbcUserDetailsService userDetailsService;
private final Environment environment;
public SecurityConfig(Environment env,
DataSource datasource,
UserSessionRepository userSessionRepository,
UserRepository userRepository,
PasswordResetRequestRepository passwordResetRequestRepository,
JdbcUserDetailsService userDetailsService,
Environment environment) {
this.env = env;
this.datasource = datasource;
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository;
this.passwordResetRequestRepository = passwordResetRequestRepository;
this.userDetailsService = userDetailsService;
this.environment = environment;
}
@Bean
public JdbcUserDetailsManager getUserDetailsManager() {
var userDetailsManager = new JdbcUserDetailsManager();
userDetailsManager.setDataSource(datasource);
return userDetailsManager;
}
@Bean
public DaoAuthenticationProvider getAuthenticationProvider() {
var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository);
authProvider.setPasswordEncoder(getPasswordEncoder());
authProvider.setUserDetailsService(userDetailsService);
return authProvider;
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Override
public void configure(AuthenticationManagerBuilder auth) {
auth.authenticationProvider(getAuthenticationProvider());
}
@Override
public void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/users/new", "/users/login")
.permitAll()
.anyRequest()
.authenticated()
.and()
.httpBasic()
.authenticationEntryPoint(new SilentAuthenticationEntryPoint())
.and()
.cors()
.configurationSource(request -> {
var corsConfig = new CorsConfiguration();
corsConfig.applyPermitDefaultValues();
var corsDomains = environment.getProperty("twigs.cors.domains", "*");
corsConfig.setAllowedOrigins(Arrays.asList(corsDomains.split(",")));
corsConfig.setAllowedMethods(
Stream.of(
HttpMethod.GET,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.DELETE,
HttpMethod.OPTIONS
)
.map(Enum::name)
.collect(Collectors.toList())
);
corsConfig.setAllowCredentials(true);
return corsConfig;
})
.and()
.csrf()
.disable()
.addFilter(new TokenAuthenticationFilter(authenticationManager()))
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}

View file

@ -1,25 +0,0 @@
package com.wbrawner.budgetserver.permission;
import com.wbrawner.budgetserver.user.UserResponse;
public class UserPermissionResponse {
private final UserResponse user;
private final Permission permission;
public UserPermissionResponse(UserPermission userPermission) {
this(new UserResponse(userPermission.getUser()), userPermission.getPermission());
}
public UserPermissionResponse(UserResponse userResponse, Permission permission) {
this.user = userResponse;
this.permission = permission;
}
public UserResponse getUser() {
return user;
}
public Permission getPermission() {
return permission;
}
}

View file

@ -1,45 +0,0 @@
package com.wbrawner.budgetserver.transaction;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.category.Category;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface TransactionRepository extends PagingAndSortingRepository<Transaction, String> {
Optional<Transaction> findByIdAndBudgetIn(String id, List<Budget> budgets);
List<Transaction> findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
List<Category> categories,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetAndCategory(Budget budget, Category category);
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 0 AND date >= :from AND date <= :to), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE Budget_id = :BudgetId AND expense = 1 AND date >= :from AND date <= :to), 0));"
)
Long sumBalanceByBudgetId(String BudgetId, Instant from, Instant to);
@Query(
nativeQuery = true,
value = "SELECT (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 0 AND date > :start), 0)) - (COALESCE((SELECT SUM(amount) from transaction WHERE category_id = :categoryId AND expense = 1 AND date > :start), 0));"
)
Long sumBalanceByCategoryId(String categoryId, Date start);
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver;
package com.wbrawner.twigs;
public class ErrorResponse {
private final String message;

View file

@ -1,11 +1,13 @@
package com.wbrawner.budgetserver.budget;
package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.transaction.TransactionRepository;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.budgetserver.user.UserRepository;
import com.wbrawner.twigs.category.CategoryRepository;
import com.wbrawner.twigs.permission.Permission;
import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.twigs.permission.UserPermissionRepository;
import com.wbrawner.twigs.transaction.TransactionRepository;
import com.wbrawner.twigs.user.User;
import com.wbrawner.twigs.user.UserRepository;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
@ -14,20 +16,20 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.time.Instant;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Function;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
import static com.wbrawner.twigs.Utils.getCurrentUser;
@RestController
@RequestMapping(value = "/budgets")
@RequestMapping(value = "/api/budgets")
@Transactional
public class BudgetController {
private final BudgetRepository budgetRepository;
private final CategoryRepository categoryRepository;
private final TransactionRepository transactionRepository;
private final UserRepository userRepository;
private final UserPermissionRepository userPermissionsRepository;
@ -35,11 +37,13 @@ public class BudgetController {
public BudgetController(
BudgetRepository budgetRepository,
CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserRepository userRepository,
UserPermissionRepository userPermissionsRepository
) {
this.budgetRepository = budgetRepository;
this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository;
this.userRepository = userRepository;
this.userPermissionsRepository = userPermissionsRepository;
@ -53,12 +57,12 @@ public class BudgetController {
}
List<BudgetResponse> budgets = userPermissionsRepository.findAllByUser(
getCurrentUser(),
PageRequest.of(
page != null ? page : 0,
count != null ? count : 1000
getCurrentUser(),
PageRequest.of(
page != null ? page : 0,
count != null ? count : 1000
)
)
)
.stream()
.map(userPermission -> {
Budget budget = userPermission.getBudget();
@ -80,35 +84,11 @@ public class BudgetController {
);
}
@GetMapping(value = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BudgetBalanceResponse> getBudgetBalance(
@PathVariable String id,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to
) {
return getBudgetWithPermission(id, Permission.READ, (budget) -> {
Instant fromInstant;
try {
fromInstant = Instant.parse(from);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e);
fromInstant = Instant.ofEpochSecond(0);
}
Instant toInstant;
try {
toInstant = Instant.parse(to);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e);
toInstant = Instant.now();
}
var balance = transactionRepository.sumBalanceByBudgetId(budget.getId(), fromInstant, toInstant);
return ResponseEntity.ok(new BudgetBalanceResponse(budget.getId(), balance));
});
}
@PostMapping(value = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
@PostMapping(
value = "",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<BudgetResponse> newBudget(@RequestBody BudgetRequest request) {
final var budget = budgetRepository.save(new Budget(request.name, request.description));
var users = request.getUsers()
@ -138,8 +118,13 @@ public class BudgetController {
return ResponseEntity.ok(new BudgetResponse(budget, new ArrayList<>(users)));
}
@PutMapping(value = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
@PutMapping(
value = "/{id}",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<BudgetResponse> updateBudget(@PathVariable String id, @RequestBody BudgetRequest request) {
// TODO: Make sure no changes in ownership are being attempted (except by the owner)
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
if (request.name != null) {
budget.setName(request.name);
@ -161,7 +146,8 @@ public class BudgetController {
)
))
));
} else {
}
if (users.isEmpty()) {
users.addAll(userPermissionsRepository.findAllByBudget(budget, null));
}
@ -169,11 +155,14 @@ public class BudgetController {
});
}
@DeleteMapping(value = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
@DeleteMapping(value = "/{id}")
public ResponseEntity<Void> deleteBudget(@PathVariable String id) {
return getBudgetWithPermission(id, Permission.MANAGE, (budget) -> {
categoryRepository.deleteAllByBudget(budget);
transactionRepository.deleteAllByBudget(budget);
userPermissionsRepository.deleteAllByBudget(budget);
budgetRepository.delete(budget);
return ResponseEntity.ok().build();
return ResponseEntity.noContent().build();
});
}

View file

@ -1,8 +1,7 @@
package com.wbrawner.budgetserver.budget;
package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.permission.UserPermissionRequest;
import com.wbrawner.twigs.permission.UserPermissionRequest;
import javax.validation.constraints.NotNull;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
@ -23,7 +22,6 @@ public class BudgetRequest {
this.users.addAll(users);
}
@NotNull
public Set<UserPermissionRequest> getUsers() {
return Set.copyOf(users);
}

View file

@ -1,8 +1,7 @@
package com.wbrawner.budgetserver.budget;
package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.twigs.permission.UserPermissionResponse;
import java.util.List;
import java.util.Objects;

View file

@ -1,10 +1,11 @@
package com.wbrawner.budgetserver.category;
package com.wbrawner.twigs.category;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.transaction.TransactionRepository;
import com.wbrawner.twigs.ErrorResponse;
import com.wbrawner.twigs.permission.Permission;
import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.twigs.permission.UserPermissionRepository;
import com.wbrawner.twigs.transaction.TransactionRepository;
import jakarta.transaction.Transactional;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.HttpStatus;
@ -12,24 +13,24 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
import static com.wbrawner.budgetserver.Utils.getFirstOfMonth;
import static com.wbrawner.twigs.Utils.getCurrentUser;
@RestController
@RequestMapping(path = "/categories")
@RequestMapping(path = "/api/categories")
@Transactional
class CategoryController {
private final CategoryRepository categoryRepository;
private final TransactionRepository transactionRepository;
private final UserPermissionRepository userPermissionsRepository;
CategoryController(CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository) {
CategoryController(
CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository
) {
this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository;
this.userPermissionsRepository = userPermissionsRepository;
@ -76,17 +77,6 @@ class CategoryController {
@GetMapping(path = "/{id}", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryResponse> getCategory(@PathVariable String id) {
var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var category = categoryRepository.findByBudgetInAndId(budgets, id).orElse(null);
if (category == null) return ResponseEntity.notFound().build();
return ResponseEntity.ok(new CategoryResponse(category));
}
@GetMapping(path = "/{id}/balance", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryBalanceResponse> getCategoryBalance(@PathVariable String id) {
var budgets = userPermissionsRepository.findAllByUser(getCurrentUser(), null)
.stream()
.map(UserPermission::getBudget)
@ -95,11 +85,14 @@ class CategoryController {
if (category == null) {
return ResponseEntity.notFound().build();
}
var sum = transactionRepository.sumBalanceByCategoryId(category.getId(), getFirstOfMonth());
return ResponseEntity.ok(new CategoryBalanceResponse(category.getId(), sum));
return ResponseEntity.ok(new CategoryResponse(category));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
@PostMapping(
path = "",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<Object> newCategory(@RequestBody NewCategoryRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null);
@ -119,12 +112,25 @@ class CategoryController {
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<CategoryResponse> updateCategory(@PathVariable String id, @RequestBody UpdateCategoryRequest request) {
@PutMapping(
path = "/{id}",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<CategoryResponse> updateCategory(
@PathVariable String id, @RequestBody UpdateCategoryRequest request
) {
var category = categoryRepository.findById(id).orElse(null);
if (category == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (category == null) {
return ResponseEntity.notFound().build();
}
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(
getCurrentUser(),
category.getBudget().getId()
).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@ -149,9 +155,16 @@ class CategoryController {
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
ResponseEntity<Void> deleteCategory(@PathVariable String id) {
var category = categoryRepository.findById(id).orElse(null);
if (category == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), category.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (category == null) {
return ResponseEntity.notFound().build();
}
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(
getCurrentUser(),
category.getBudget().getId()
).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.category;
package com.wbrawner.twigs.category;
import java.util.Objects;
@ -23,7 +23,9 @@ public class CategoryResponse {
);
}
public CategoryResponse(String id, String title, String description, long amount, String budgetId, boolean expense, boolean archived) {
public CategoryResponse(
String id, String title, String description, long amount, String budgetId, boolean expense, boolean archived
) {
this.id = id;
this.title = title;
this.description = description;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.category;
package com.wbrawner.twigs.category;
public class NewCategoryRequest {
private final String title;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.category;
package com.wbrawner.twigs.category;
public class UpdateCategoryRequest {
private final String title;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.permission;
package com.wbrawner.twigs.permission;
public class UserPermissionRequest {
private final String user;

View file

@ -0,0 +1,25 @@
package com.wbrawner.twigs.permission;
import com.wbrawner.twigs.user.User;
public class UserPermissionResponse {
private final String user;
private final Permission permission;
public UserPermissionResponse(UserPermission userPermission) {
this(userPermission.getUser(), userPermission.getPermission());
}
public UserPermissionResponse(User user, Permission permission) {
this.user = user.getId();
this.permission = permission;
}
public String getUser() {
return user;
}
public Permission getPermission() {
return permission;
}
}

View file

@ -1,20 +1,27 @@
package com.wbrawner.budgetserver.session;
package com.wbrawner.twigs.session;
import java.util.Date;
public class SessionResponse {
private final String userId;
private final String token;
private final String expiration;
public SessionResponse(Session session) {
this(session.getToken(), session.getExpiration());
this(session.getUserId(), session.getToken(), session.getExpiration());
}
public SessionResponse(String token, Date expiration) {
public SessionResponse(String userId, String token, Date expiration) {
this.userId = userId;
this.token = token;
this.expiration = expiration.toInstant().toString();
}
public String getUserId() {
return userId;
}
public String getToken() {
return token;
}

View file

@ -1,10 +1,10 @@
package com.wbrawner.budgetserver.category;
package com.wbrawner.twigs.transaction;
public class CategoryBalanceResponse {
public class BalanceResponse {
private final String id;
private final long balance;
public CategoryBalanceResponse(String id, long balance) {
public BalanceResponse(String id, long balance) {
this.id = id;
this.balance = balance;
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.transaction;
package com.wbrawner.twigs.transaction;
class NewTransactionRequest {
private final String title;
@ -13,7 +13,10 @@ class NewTransactionRequest {
this(null, null, null, null, null, null, null);
}
NewTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId) {
NewTransactionRequest(
String title, String description, String date, Long amount, String categoryId, Boolean expense,
String budgetId
) {
this.title = title;
this.description = description;
this.date = date;

View file

@ -1,11 +1,12 @@
package com.wbrawner.budgetserver.transaction;
package com.wbrawner.twigs.transaction;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.category.CategoryRepository;
import com.wbrawner.budgetserver.permission.Permission;
import com.wbrawner.budgetserver.permission.UserPermission;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.twigs.ErrorResponse;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.category.CategoryRepository;
import com.wbrawner.twigs.permission.Permission;
import com.wbrawner.twigs.permission.UserPermission;
import com.wbrawner.twigs.permission.UserPermissionRepository;
import jakarta.transaction.Transactional;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.domain.PageRequest;
@ -15,15 +16,16 @@ import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.time.Instant;
import java.util.Collections;
import java.util.List;
import java.util.concurrent.atomic.AtomicLong;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.*;
import static com.wbrawner.twigs.Utils.*;
@RestController
@RequestMapping(path = "/transactions")
@RequestMapping(path = "/api/transactions")
@Transactional
public class TransactionController {
private final CategoryRepository categoryRepository;
@ -32,9 +34,11 @@ public class TransactionController {
private final Logger logger = LoggerFactory.getLogger(TransactionController.class);
public TransactionController(CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository) {
public TransactionController(
CategoryRepository categoryRepository,
TransactionRepository transactionRepository,
UserPermissionRepository userPermissionsRepository
) {
this.categoryRepository = categoryRepository;
this.transactionRepository = transactionRepository;
this.userPermissionsRepository = userPermissionsRepository;
@ -79,16 +83,18 @@ public class TransactionController {
try {
fromInstant = Instant.parse(from);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
if (!(e instanceof NullPointerException)) {
logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e);
}
fromInstant = getFirstOfMonth().toInstant();
}
Instant toInstant;
try {
toInstant = Instant.parse(to);
} catch (Exception e) {
if (!(e instanceof NullPointerException))
if (!(e instanceof NullPointerException)) {
logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e);
}
toInstant = getEndOfMonth().toInstant();
}
var query = categories == null ? transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
@ -117,11 +123,17 @@ public class TransactionController {
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var transaction = transactionRepository.findByIdAndBudgetIn(id, budgets).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
if (transaction == null) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(new TransactionResponse(transaction));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
@PostMapping(
path = "",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<Object> newTransaction(@RequestBody NewTransactionRequest request) {
var userResponse = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId())
.orElse(null);
@ -148,12 +160,25 @@ public class TransactionController {
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<Object> updateTransaction(@PathVariable String id, @RequestBody UpdateTransactionRequest request) {
@PutMapping(
path = "/{id}",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
public ResponseEntity<Object> updateTransaction(
@PathVariable String id, @RequestBody UpdateTransactionRequest request
) {
var transaction = transactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
if (transaction == null) {
return ResponseEntity.notFound().build();
}
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(
getCurrentUser(),
transaction.getBudget().getId()
).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
@ -173,7 +198,10 @@ public class TransactionController {
transaction.setExpense(request.getExpense());
}
if (request.getBudgetId() != null) {
var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), request.getBudgetId()).orElse(null);
var newUserPermission = userPermissionsRepository.findByUserAndBudget_Id(
getCurrentUser(),
request.getBudgetId()
).orElse(null);
if (newUserPermission == null || newUserPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity
.badRequest()
@ -182,7 +210,8 @@ public class TransactionController {
transaction.setBudget(newUserPermission.getBudget());
}
if (request.getCategoryId() != null) {
var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId()).orElse(null);
var category = categoryRepository.findByBudgetAndId(transaction.getBudget(), request.getCategoryId())
.orElse(null);
if (category == null) {
return ResponseEntity
.badRequest()
@ -196,14 +225,111 @@ public class TransactionController {
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
public ResponseEntity<Void> deleteTransaction(@PathVariable String id) {
var transaction = transactionRepository.findById(id).orElse(null);
if (transaction == null) return ResponseEntity.notFound().build();
if (transaction == null) {
return ResponseEntity.notFound().build();
}
// Check that the transaction belongs to an budget that the user has access to before deleting it
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(getCurrentUser(), transaction.getBudget().getId()).orElse(null);
if (userPermission == null) return ResponseEntity.notFound().build();
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(
getCurrentUser(),
transaction.getBudget().getId()
).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.WRITE)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
transactionRepository.delete(transaction);
return ResponseEntity.ok().build();
}
@GetMapping(value = "/sum", produces = {MediaType.APPLICATION_JSON_VALUE})
public ResponseEntity<BalanceResponse> getSum(
@RequestParam(value = "budgetId", required = false) String budgetId,
@RequestParam(value = "categoryId", required = false) String categoryId,
@RequestParam(value = "from", required = false) String from,
@RequestParam(value = "to", required = false) String to
) {
var user = getCurrentUser();
if (user == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
Instant fromInstant;
try {
fromInstant = Instant.parse(from);
} catch (Exception e) {
if (!(e instanceof NullPointerException)) {
logger.error("Failed to parse '" + from + "' to Instant for 'from' parameter", e);
}
fromInstant = Instant.ofEpochSecond(0);
}
Instant toInstant;
try {
toInstant = Instant.parse(to);
} catch (Exception e) {
if (!(e instanceof NullPointerException)) {
logger.error("Failed to parse '" + to + "' to Instant for 'to' parameter", e);
}
toInstant = Instant.now();
}
if (((budgetId == null || budgetId.isBlank())
&& (categoryId == null || categoryId.isBlank()))
|| (budgetId != null && !budgetId.isEmpty() && categoryId != null && !categoryId.isBlank())) {
return ResponseEntity.badRequest().build();
}
List<Transaction> transactions;
if (categoryId != null) {
var budgets = userPermissionsRepository.findAllByUser(user, null)
.stream()
.map(UserPermission::getBudget)
.collect(Collectors.toList());
var category = categoryRepository.findByBudgetInAndId(budgets, categoryId).orElse(null);
if (category == null) {
return ResponseEntity.notFound().build();
}
transactions = transactionRepository.findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
List.of(category.getBudget()),
List.of(category),
fromInstant,
toInstant,
null
);
AtomicLong balance = new AtomicLong(0L);
transactions.forEach(transaction -> {
if (transaction.getExpense()) {
balance.addAndGet(transaction.getAmount() * -1);
} else {
balance.addAndGet(transaction.getAmount());
}
});
return ResponseEntity.ok(new BalanceResponse(categoryId, balance.get()));
} else {
var userPermission = userPermissionsRepository.findByUserAndBudget_Id(user, budgetId).orElse(null);
if (userPermission == null) {
return ResponseEntity.notFound().build();
}
if (userPermission.getPermission().isNotAtLeast(Permission.READ)) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
transactions = transactionRepository.findAllByBudgetInAndDateGreaterThanAndDateLessThan(
List.of(userPermission.getBudget()),
fromInstant,
toInstant,
null
);
AtomicLong balance = new AtomicLong(0L);
transactions.forEach(transaction -> {
if (transaction.getExpense()) {
balance.addAndGet(transaction.getAmount() * -1);
} else {
balance.addAndGet(transaction.getAmount());
}
});
return ResponseEntity.ok(new BalanceResponse(budgetId, balance.get()));
}
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.transaction;
package com.wbrawner.twigs.transaction;
class TransactionResponse {
private final String id;
@ -11,15 +11,17 @@ class TransactionResponse {
private final String categoryId;
private final String createdBy;
TransactionResponse(String id,
String title,
String description,
String date,
Long amount,
Boolean expense,
String budgetId,
String categoryId,
String createdBy) {
TransactionResponse(
String id,
String title,
String description,
String date,
Long amount,
Boolean expense,
String budgetId,
String categoryId,
String createdBy
) {
this.id = id;
this.title = title;
this.description = description;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.transaction;
package com.wbrawner.twigs.transaction;
class UpdateTransactionRequest {
private final String title;
@ -14,7 +14,10 @@ class UpdateTransactionRequest {
this(null, null, null, null, null, null, null, null);
}
UpdateTransactionRequest(String title, String description, String date, Long amount, String categoryId, Boolean expense, String budgetId, String createdBy) {
UpdateTransactionRequest(
String title, String description, String date, Long amount, String categoryId, Boolean expense,
String budgetId, String createdBy
) {
this.title = title;
this.description = description;
this.date = date;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
public class LoginRequest {
private final String username;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
public class NewUserRequest {
private final String username;

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
public class UpdateUserRequest {
private final String username;

View file

@ -1,13 +1,15 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
import com.wbrawner.budgetserver.ErrorResponse;
import com.wbrawner.budgetserver.budget.BudgetRepository;
import com.wbrawner.budgetserver.permission.UserPermissionRepository;
import com.wbrawner.budgetserver.permission.UserPermissionResponse;
import com.wbrawner.budgetserver.session.Session;
import com.wbrawner.budgetserver.session.SessionResponse;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.twigs.ErrorResponse;
import com.wbrawner.twigs.budget.BudgetRepository;
import com.wbrawner.twigs.permission.UserPermissionRepository;
import com.wbrawner.twigs.permission.UserPermissionResponse;
import com.wbrawner.twigs.session.Session;
import com.wbrawner.twigs.session.SessionResponse;
import com.wbrawner.twigs.session.UserSessionRepository;
import jakarta.transaction.Transactional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -18,15 +20,14 @@ import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import javax.transaction.Transactional;
import java.util.List;
import java.util.Objects;
import java.util.stream.Collectors;
import static com.wbrawner.budgetserver.Utils.getCurrentUser;
import static com.wbrawner.twigs.Utils.getCurrentUser;
@RestController
@RequestMapping("/users")
@RequestMapping("/api/users")
@Transactional
public class UserController {
private final BudgetRepository budgetRepository;
@ -34,20 +35,24 @@ public class UserController {
private final PasswordEncoder passwordEncoder;
private final UserPermissionRepository userPermissionsRepository;
private final UserSessionRepository userSessionRepository;
private final UserService userService;
private final DaoAuthenticationProvider authenticationProvider;
@Autowired
public UserController(BudgetRepository budgetRepository,
UserRepository userRepository,
UserSessionRepository userSessionRepository,
PasswordEncoder passwordEncoder,
UserPermissionRepository userPermissionsRepository,
DaoAuthenticationProvider authenticationProvider) {
public UserController(
BudgetRepository budgetRepository,
UserRepository userRepository,
UserSessionRepository userSessionRepository,
PasswordEncoder passwordEncoder,
UserPermissionRepository userPermissionsRepository,
UserService userService, DaoAuthenticationProvider authenticationProvider
) {
this.budgetRepository = budgetRepository;
this.userRepository = userRepository;
this.userSessionRepository = userSessionRepository;
this.passwordEncoder = passwordEncoder;
this.userPermissionsRepository = userPermissionsRepository;
this.userService = userService;
this.authenticationProvider = authenticationProvider;
}
@ -66,28 +71,28 @@ public class UserController {
if (!userInBudget) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(userPermissions.stream().map(UserPermissionResponse::new).collect(Collectors.toList()));
return ResponseEntity.ok(userPermissions.stream()
.map(UserPermissionResponse::new)
.collect(Collectors.toList()));
}
@PostMapping(path = "/login", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<SessionResponse> login(@RequestBody LoginRequest request) {
var authReq = new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword());
Authentication auth;
try {
auth = authenticationProvider.authenticate(authReq);
return ResponseEntity.ok(new SessionResponse(
userService.login(request.getUsername(), request.getPassword())
));
} catch (AuthenticationException e) {
return ResponseEntity.notFound().build();
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
SecurityContextHolder.getContext().setAuthentication(auth);
var user = Objects.requireNonNull(getCurrentUser());
var session = userSessionRepository.save(new Session(user.getId()));
return ResponseEntity.ok(new SessionResponse(session));
}
@GetMapping(path = "/me", produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<UserResponse> getProfile() {
var user = getCurrentUser();
if (user == null) return ResponseEntity.status(401).build();
if (user == null) {
return ResponseEntity.status(401).build();
}
return ResponseEntity.ok(new UserResponse(user));
}
@ -110,14 +115,21 @@ public class UserController {
return ResponseEntity.ok(new UserResponse(user));
}
@PostMapping(path = "", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
@PostMapping(
path = "/register",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<Object> newUser(@RequestBody NewUserRequest request) {
if (userRepository.findByUsername(request.getUsername()).isPresent())
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Username taken"));
if (userRepository.findByEmail(request.getEmail()).isPresent())
}
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Email taken"));
if (request.getPassword().isBlank())
}
if (request.getPassword().isBlank()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password"));
}
return ResponseEntity.ok(new UserResponse(userRepository.save(new User(
request.getUsername(),
passwordEncoder.encode(request.getPassword()),
@ -125,24 +137,36 @@ public class UserController {
))));
}
@PutMapping(path = "/{id}", consumes = {MediaType.APPLICATION_JSON_VALUE}, produces = {MediaType.APPLICATION_JSON_VALUE})
ResponseEntity<Object> updateUser(@PathVariable Long id, @RequestBody UpdateUserRequest request) {
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build();
@PutMapping(
path = "/{id}",
consumes = {MediaType.APPLICATION_JSON_VALUE},
produces = {MediaType.APPLICATION_JSON_VALUE}
)
ResponseEntity<Object> updateUser(@PathVariable String id, @RequestBody UpdateUserRequest request) {
var currentUser = getCurrentUser();
if (currentUser == null || !currentUser.getId().equals(id)) {
return ResponseEntity.status(403).build();
}
var user = userRepository.findById(getCurrentUser().getId()).orElse(null);
if (user == null) return ResponseEntity.notFound().build();
if (user == null) {
return ResponseEntity.notFound().build();
}
if (request.getUsername() != null) {
if (userRepository.findByUsername(request.getUsername()).isPresent())
if (userRepository.findByUsername(request.getUsername()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Username taken"));
}
user.setUsername(request.getUsername());
}
if (request.getEmail() != null) {
if (userRepository.findByEmail(request.getEmail()).isPresent())
if (userRepository.findByEmail(request.getEmail()).isPresent()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Email taken"));
}
user.setEmail(request.getEmail());
}
if (request.getPassword() != null) {
if (request.getPassword().isBlank())
if (request.getPassword().isBlank()) {
return ResponseEntity.badRequest().body(new ErrorResponse("Invalid password"));
}
user.setPassword(passwordEncoder.encode(request.getPassword()));
}
return ResponseEntity.ok(new UserResponse(userRepository.save(user)));
@ -150,7 +174,10 @@ public class UserController {
@DeleteMapping(path = "/{id}", produces = {MediaType.TEXT_PLAIN_VALUE})
ResponseEntity<Void> deleteUser(@PathVariable String id) {
if (!getCurrentUser().getId().equals(id)) return ResponseEntity.status(403).build();
var currentUser = getCurrentUser();
if (currentUser == null || !currentUser.getId().equals(id)) {
return ResponseEntity.status(403).build();
}
userRepository.deleteById(id);
return ResponseEntity.ok().build();
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
public class UserResponse {
private final String id;

View file

@ -1,10 +1,6 @@
spring.jpa.hibernate.ddl-auto=none
spring.datasource.url=jdbc:mysql://localhost:3306/budget
spring.datasource.username=budget
spring.datasource.password=budget
spring.jpa.hibernate.ddl-auto=update
spring.datasource.url=jdbc:h2:./twigs.db
spring.profiles.active=prod
spring.session.jdbc.initialize-schema=always
spring.datasource.testWhileIdle=true
spring.datasource.timeBetweenEvictionRunsMillis=60000
spring.datasource.validationQuery=SELECT 1
twigs.cors.domains=*
twigs.cors.domains=http://localhost:4200
logging.level.org.springframework.security=DEBUG

25
app/build.gradle Normal file
View file

@ -0,0 +1,25 @@
plugins {
id 'java'
id 'application'
id "io.spring.dependency-management"
id "org.springframework.boot"
id 'org.graalvm.buildtools.native' version '0.9.18'
}
dependencies {
implementation project(':core')
implementation project(':api')
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.session:spring-session-jdbc"
runtimeOnly 'org.postgresql:postgresql:42.2.27'
runtimeOnly 'com.h2database:h2:2.2.220'
testImplementation "org.springframework.boot:spring-boot-starter-test"
}
jar {
description = "twigs"
}
mainClassName = "com.wbrawner.twigs.TwigsServerApplication"

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver;
package com.wbrawner.twigs;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

View file

@ -1,6 +1,6 @@
package com.wbrawner.budgetserver.config;
package com.wbrawner.twigs.config;
import com.wbrawner.budgetserver.user.UserRepository;
import com.wbrawner.twigs.user.UserRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;

View file

@ -0,0 +1,127 @@
package com.wbrawner.twigs.config;
import com.wbrawner.twigs.user.PasswordResetRequestRepository;
import com.wbrawner.twigs.session.UserSessionRepository;
import com.wbrawner.twigs.user.UserRepository;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.env.Environment;
import org.springframework.http.HttpMethod;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfiguration;
import javax.sql.DataSource;
import java.util.Arrays;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Configuration
public class SecurityConfig {
private final Environment env;
private final DataSource datasource;
private final UserSessionRepository userSessionRepository;
private final UserRepository userRepository;
private final PasswordResetRequestRepository passwordResetRequestRepository;
private final JdbcUserDetailsService userDetailsService;
private final Environment environment;
public SecurityConfig(
Environment env,
DataSource datasource,
UserSessionRepository userSessionRepository,
UserRepository userRepository,
PasswordResetRequestRepository passwordResetRequestRepository,
JdbcUserDetailsService userDetailsService,
Environment environment
) {
this.env = env;
this.datasource = datasource;
this.userSessionRepository = userSessionRepository;
this.userRepository = userRepository;
this.passwordResetRequestRepository = passwordResetRequestRepository;
this.userDetailsService = userDetailsService;
this.environment = environment;
}
@Bean
public JdbcUserDetailsManager getUserDetailsManager() {
var userDetailsManager = new JdbcUserDetailsManager();
userDetailsManager.setDataSource(datasource);
return userDetailsManager;
}
@Bean
public DaoAuthenticationProvider getAuthenticationProvider() {
var authProvider = new TokenAuthenticationProvider(userSessionRepository, userRepository);
authProvider.setPasswordEncoder(getPasswordEncoder());
authProvider.setUserDetailsService(userDetailsService);
return authProvider;
}
@Bean
public PasswordEncoder getPasswordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public SecurityFilterChain filterChain(
HttpSecurity httpSecurity, AuthenticationManager authenticationManager
) throws Exception {
return httpSecurity.authorizeHttpRequests((authz) -> {
try {
authz
.requestMatchers(
"/api/^(users/register|users/login)"
)
.authenticated()
.anyRequest()
.permitAll()
.and()
.httpBasic()
.authenticationEntryPoint(new SilentAuthenticationEntryPoint())
.and()
.cors()
.configurationSource(request -> {
var corsConfig = new CorsConfiguration();
corsConfig.applyPermitDefaultValues();
var corsDomains = environment.getProperty("twigs.cors.domains", "*");
corsConfig.setAllowedOrigins(Arrays.asList(corsDomains.split(",")));
corsConfig.setAllowedMethods(
Stream.of(
HttpMethod.GET,
HttpMethod.POST,
HttpMethod.PUT,
HttpMethod.DELETE,
HttpMethod.OPTIONS
)
.map(HttpMethod::name)
.collect(Collectors.toList())
);
corsConfig.setAllowCredentials(true);
return corsConfig;
})
.and()
.csrf()
.disable()
.addFilter(new TokenAuthenticationFilter(authenticationManager));
} catch (Exception e) {
throw new RuntimeException(e);
}
})
.build();
}
@Bean
public AuthenticationManager getAuthenticationManager(AuthenticationConfiguration auth) throws Exception {
return auth.getAuthenticationManager();
}
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.config;
package com.wbrawner.twigs.config;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
@ -14,7 +14,9 @@ public class SessionAuthenticationToken extends UsernamePasswordAuthenticationTo
* @param credentials
* @param principal
*/
public SessionAuthenticationToken(Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities) {
public SessionAuthenticationToken(
Object principal, Object credentials, Collection<? extends GrantedAuthority> authorities
) {
super(principal, credentials, authorities);
}
}

View file

@ -1,11 +1,10 @@
package com.wbrawner.budgetserver.config;
package com.wbrawner.twigs.config;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
/**
@ -13,7 +12,9 @@ import java.io.IOException;
*/
public class SilentAuthenticationEntryPoint implements AuthenticationEntryPoint {
@Override
public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
public void commence(
HttpServletRequest request, HttpServletResponse response, AuthenticationException authException
) throws IOException {
response.sendError(HttpServletResponse.SC_UNAUTHORIZED, authException.getMessage());
}
}

View file

@ -1,13 +1,13 @@
package com.wbrawner.budgetserver.config;
package com.wbrawner.twigs.config;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.www.BasicAuthenticationFilter;
import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
@ -18,14 +18,20 @@ public class TokenAuthenticationFilter extends BasicAuthenticationFilter {
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws IOException, ServletException {
protected void doFilterInternal(
HttpServletRequest request, HttpServletResponse response, FilterChain chain
) throws IOException, ServletException {
var authHeader = request.getHeader("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
chain.doFilter(request, response);
return;
}
var token = authHeader.substring(7);
var authentication = getAuthenticationManager().authenticate(new SessionAuthenticationToken(null, token, Collections.emptyList()));
var authentication = getAuthenticationManager().authenticate(new SessionAuthenticationToken(
null,
token,
Collections.emptyList()
));
SecurityContextHolder.getContext().setAuthentication(authentication);
chain.doFilter(request, response);
}

View file

@ -1,7 +1,8 @@
package com.wbrawner.budgetserver.config;
package com.wbrawner.twigs.config;
import com.wbrawner.budgetserver.session.UserSessionRepository;
import com.wbrawner.budgetserver.user.UserRepository;
import com.wbrawner.twigs.Utils;
import com.wbrawner.twigs.session.UserSessionRepository;
import com.wbrawner.twigs.user.UserRepository;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.InternalAuthenticationServiceException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
@ -12,7 +13,7 @@ import org.springframework.security.core.userdetails.UserDetails;
import java.util.Date;
import static com.wbrawner.budgetserver.Utils.twoWeeksFromNow;
import static com.wbrawner.twigs.Utils.twoWeeksFromNow;
public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
private final UserSessionRepository userSessionRepository;
@ -24,7 +25,9 @@ public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
}
@Override
protected void additionalAuthenticationChecks(UserDetails userDetails, UsernamePasswordAuthenticationToken authentication) throws AuthenticationException {
protected void additionalAuthenticationChecks(
UserDetails userDetails, UsernamePasswordAuthenticationToken authentication
) throws AuthenticationException {
if (!(authentication instanceof SessionAuthenticationToken)) {
// Additional checks aren't needed since they've already been handled
super.additionalAuthenticationChecks(userDetails, authentication);
@ -45,10 +48,14 @@ public class TokenAuthenticationProvider extends DaoAuthenticationProvider {
new Thread(() -> {
// Update the session on a background thread to avoid holding up the request longer than necessary
var updatedSession = session.get();
updatedSession.setExpiration(twoWeeksFromNow());
updatedSession.setExpiration(Utils.twoWeeksFromNow());
userSessionRepository.save(updatedSession);
}).start();
return new SessionAuthenticationToken(user.get(), authentication.getCredentials(), authentication.getAuthorities());
return new SessionAuthenticationToken(
user.get(),
authentication.getCredentials(),
authentication.getAuthorities()
);
} else {
return super.authenticate(authentication);
}

View file

@ -0,0 +1,22 @@
//package com.wbrawner.twigs.config;
//
//import org.springframework.context.annotation.Configuration;
//import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
//import org.springframework.web.servlet.config.annotation.ViewControllerRegistry;
//import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
//
//@Configuration
//public class WebConfig implements WebMvcConfigurer {
//
// @Override
// public void addViewControllers(ViewControllerRegistry registry) {
// registry.addViewController("/").setViewName("forward:/index.html");
// registry.addViewController("/{path:\\w*}").setViewName("forward:/index.html");
// registry.addViewController("/(^api)/**").setViewName("forward:/index.html");
// }
//
// @Override
// public void addResourceHandlers(ResourceHandlerRegistry registry) {
// registry.addResourceHandler("/**").addResourceLocations("classpath:/webapp/");
// }
//}

View file

@ -2,12 +2,12 @@ buildscript {
repositories {
mavenLocal()
mavenCentral()
maven { url "http://repo.spring.io/snapshot" }
maven { url "http://repo.spring.io/milestone" }
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
}
dependencies {
classpath "org.springframework.boot:spring-boot-gradle-plugin:2.2.2.RELEASE"
classpath "org.springframework.boot:spring-boot-gradle-plugin:3.0.3"
}
}
@ -17,9 +17,9 @@ allprojects {
repositories {
mavenLocal()
mavenCentral()
maven { url "http://repo.spring.io/snapshot" }
maven { url "http://repo.spring.io/milestone" }
maven { url "http://repo.maven.apache.org/maven2" }
maven { url "https://repo.spring.io/snapshot" }
maven { url "https://repo.spring.io/milestone" }
maven { url "https://repo.maven.apache.org/maven2" }
}
jar {

12
core/build.gradle Normal file
View file

@ -0,0 +1,12 @@
plugins {
id 'java'
id 'application'
id "io.spring.dependency-management"
id "org.springframework.boot"
}
dependencies {
implementation "org.springframework.boot:spring-boot-starter-security"
implementation "org.springframework.boot:spring-boot-starter-web"
implementation "org.springframework.boot:spring-boot-starter-data-jpa"
}

View file

@ -1,6 +1,6 @@
package com.wbrawner.budgetserver;
package com.wbrawner.twigs;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.twigs.user.User;
import org.springframework.security.core.context.SecurityContextHolder;
import java.security.SecureRandom;

View file

@ -1,19 +1,22 @@
package com.wbrawner.budgetserver.budget;
package com.wbrawner.twigs.budget;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.transaction.Transaction;
import com.wbrawner.twigs.Utils;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.transaction.Transaction;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.OneToMany;
import javax.persistence.*;
import java.util.HashSet;
import java.util.Set;
import java.util.TreeSet;
import static com.wbrawner.budgetserver.Utils.randomId;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
public class Budget {
@Id
private String id = randomId();
private String id = Utils.randomId();
private String name;
private String description;
private String currencyCode;
@ -24,7 +27,8 @@ public class Budget {
@OneToMany(mappedBy = "budget")
private final Set<Transaction> users = new HashSet<>();
public Budget() {}
public Budget() {
}
public Budget(String name, String description) {
this(name, description, "USD");

View file

@ -0,0 +1,8 @@
package com.wbrawner.twigs.budget;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
public interface BudgetRepository extends CrudRepository<Budget, String>,
PagingAndSortingRepository<Budget, String> {
}

View file

@ -1,11 +1,9 @@
package com.wbrawner.budgetserver.category;
package com.wbrawner.twigs.category;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.twigs.budget.Budget;
import jakarta.persistence.*;
import javax.persistence.*;
import java.util.UUID;
import static com.wbrawner.budgetserver.Utils.randomId;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
public class Category implements Comparable<Category> {

View file

@ -1,15 +1,16 @@
package com.wbrawner.budgetserver.category;
import com.wbrawner.budgetserver.budget.Budget;
package com.wbrawner.twigs.category;
import com.wbrawner.twigs.budget.Budget;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface CategoryRepository extends PagingAndSortingRepository<Category, String> {
public interface CategoryRepository extends CrudRepository<Category, String>,
PagingAndSortingRepository<Category, String> {
List<Category> findAllByBudget(Budget budget, Pageable pageable);
@Query("SELECT c FROM Category c where c.budget IN (:budgets) AND (:expense IS NULL OR c.expense = :expense) AND (:archived IS NULL OR c.archived = :archived)")
@ -20,4 +21,6 @@ public interface CategoryRepository extends PagingAndSortingRepository<Category,
Optional<Category> findByBudgetAndId(Budget budget, String id);
List<Category> findAllByBudgetInAndIdIn(List<Budget> budgets, List<String> ids, Pageable pageable);
void deleteAllByBudget(Budget budget);
}

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.permission;
package com.wbrawner.twigs.permission;
public enum Permission {
/**

View file

@ -1,9 +1,8 @@
package com.wbrawner.budgetserver.permission;
package com.wbrawner.twigs.permission;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.user.User;
import javax.persistence.*;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.user.User;
import jakarta.persistence.*;
@Entity
public class UserPermission {

View file

@ -1,6 +1,7 @@
package com.wbrawner.budgetserver.permission;
package com.wbrawner.twigs.permission;
import jakarta.persistence.Embeddable;
import javax.persistence.Embeddable;
import java.io.Serializable;
@Embeddable

View file

@ -1,14 +1,16 @@
package com.wbrawner.budgetserver.permission;
package com.wbrawner.twigs.permission;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.user.User;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface UserPermissionRepository extends PagingAndSortingRepository<UserPermission, UserPermissionKey> {
public interface UserPermissionRepository extends CrudRepository<UserPermission, UserPermissionKey>,
PagingAndSortingRepository<UserPermission, UserPermissionKey> {
Optional<UserPermission> findByUserAndBudget_Id(User user, String budgetId);
List<UserPermission> findAllByUser(User user, Pageable pageable);
@ -18,4 +20,6 @@ public interface UserPermissionRepository extends PagingAndSortingRepository<Use
List<UserPermission> findAllByUserAndBudget(User user, Budget budget, Pageable pageable);
List<UserPermission> findAllByUserAndBudget_IdIn(User user, List<String> budgetIds, Pageable pageable);
void deleteAllByBudget(Budget budget);
}

View file

@ -0,0 +1,127 @@
package com.wbrawner.twigs.recurringtransaction;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.user.User;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import java.time.Instant;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
public class RecurringTransaction implements Comparable<RecurringTransaction> {
@Id
private final String id = randomId();
@ManyToOne
@JoinColumn(nullable = false)
private final User createdBy;
private String title;
private String description;
private Instant date;
private Long amount;
@ManyToOne
private Category category;
private Boolean expense;
@ManyToOne
@JoinColumn(nullable = false)
private Budget budget;
public RecurringTransaction() {
this(null, null, null, null, null, null, null, null);
}
public RecurringTransaction(
String title,
String description,
Instant date,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget
) {
this.title = title;
this.description = description;
this.date = date;
this.amount = amount;
this.category = category;
this.expense = expense;
this.createdBy = createdBy;
this.budget = budget;
}
public String getId() {
// This should only be set from Hibernate so it shouldn't actually be null ever
//noinspection ConstantConditions
return id;
}
public String getTitle() {
return title;
}
public void setTitle(String title) {
this.title = title;
}
public String getDescription() {
return description;
}
public void setDescription(String description) {
this.description = description;
}
public Instant getDate() {
return date;
}
public void setDate(Instant date) {
this.date = date;
}
public Long getAmount() {
return amount;
}
public void setAmount(Long amount) {
this.amount = amount;
}
public Category getCategory() {
return category;
}
public void setCategory(Category category) {
this.category = category;
}
public Boolean getExpense() {
return expense;
}
public void setExpense(Boolean expense) {
this.expense = expense;
}
public User getCreatedBy() {
return createdBy;
}
public Budget getBudget() {
return budget;
}
public void setBudget(Budget budget) {
this.budget = budget;
}
@Override
public int compareTo(RecurringTransaction other) {
return this.date.compareTo(other.date);
}
}

View file

@ -0,0 +1,36 @@
package com.wbrawner.twigs.recurringtransaction;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.transaction.Transaction;
import org.springframework.data.domain.Pageable;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
public interface RecurringTransactionRepository extends CrudRepository<RecurringTransaction, String>,
PagingAndSortingRepository<RecurringTransaction, String> {
Optional<Transaction> findByIdAndBudgetIn(String id, List<Budget> budgets);
List<Transaction> findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
List<Category> categories,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetAndCategory(Budget budget, Category category);
void deleteAllByBudget(Budget budget);
}

View file

@ -1,10 +1,11 @@
package com.wbrawner.budgetserver.session;
package com.wbrawner.twigs.session;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import javax.persistence.Entity;
import javax.persistence.Id;
import java.util.Date;
import static com.wbrawner.budgetserver.Utils.*;
import static com.wbrawner.twigs.Utils.*;
@Entity
public class Session {

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.session;
package com.wbrawner.twigs.session;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.scheduling.annotation.Scheduled;

View file

@ -1,12 +1,14 @@
package com.wbrawner.budgetserver.session;
package com.wbrawner.twigs.session;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface UserSessionRepository extends PagingAndSortingRepository<Session, String> {
public interface UserSessionRepository extends CrudRepository<Session, String>,
PagingAndSortingRepository<Session, String> {
List<Session> findByUserId(String userId);
Optional<Session> findByToken(String token);

View file

@ -1,13 +1,16 @@
package com.wbrawner.budgetserver.transaction;
package com.wbrawner.twigs.transaction;
import com.wbrawner.budgetserver.budget.Budget;
import com.wbrawner.budgetserver.category.Category;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import com.wbrawner.twigs.user.User;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import javax.persistence.*;
import java.time.Instant;
import static com.wbrawner.budgetserver.Utils.randomId;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
public class Transaction implements Comparable<Transaction> {
@ -31,14 +34,16 @@ public class Transaction implements Comparable<Transaction> {
this(null, null, null, null, null, null, null, null);
}
public Transaction(String title,
String description,
Instant date,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget) {
public Transaction(
String title,
String description,
Instant date,
Long amount,
Category category,
Boolean expense,
User createdBy,
Budget budget
) {
this.title = title;
this.description = description;
this.date = date;

View file

@ -0,0 +1,37 @@
package com.wbrawner.twigs.transaction;
import com.wbrawner.twigs.budget.Budget;
import com.wbrawner.twigs.category.Category;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.Optional;
public interface TransactionRepository extends CrudRepository<Transaction, String>,
PagingAndSortingRepository<Transaction, String> {
Optional<Transaction> findByIdAndBudgetIn(String id, List<Budget> budgets);
List<Transaction> findAllByBudgetInAndCategoryInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
List<Category> categories,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetInAndDateGreaterThanAndDateLessThan(
List<Budget> budgets,
Instant start,
Instant end,
Pageable pageable
);
List<Transaction> findAllByBudgetAndCategory(Budget budget, Category category);
void deleteAllByBudget(Budget budget);
}

View file

@ -1,13 +1,14 @@
package com.wbrawner.budgetserver.passwordresetrequest;
package com.wbrawner.twigs.user;
import com.wbrawner.budgetserver.user.User;
import com.wbrawner.twigs.user.User;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.ManyToOne;
import javax.persistence.*;
import java.util.Calendar;
import java.util.GregorianCalendar;
import java.util.UUID;
import static com.wbrawner.budgetserver.Utils.randomId;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
public class PasswordResetRequest {

View file

@ -1,4 +1,4 @@
package com.wbrawner.budgetserver.passwordresetrequest;
package com.wbrawner.twigs.user;
import org.springframework.data.repository.PagingAndSortingRepository;

View file

@ -1,20 +1,20 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Transient;
import org.springframework.lang.NonNull;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import javax.persistence.Entity;
import javax.persistence.Id;
import javax.persistence.Transient;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import static com.wbrawner.budgetserver.Utils.randomId;
import static com.wbrawner.twigs.Utils.randomId;
@Entity
@Entity(name = "users")
public class User implements UserDetails {
@Id
private final String id = randomId();

View file

@ -1,11 +1,13 @@
package com.wbrawner.budgetserver.user;
package com.wbrawner.twigs.user;
import org.springframework.data.repository.CrudRepository;
import org.springframework.data.repository.PagingAndSortingRepository;
import java.util.List;
import java.util.Optional;
public interface UserRepository extends PagingAndSortingRepository<User, String> {
public interface UserRepository extends CrudRepository<User, String>,
PagingAndSortingRepository<User, String> {
Optional<User> findByUsername(String username);
Optional<User> findByUsernameAndPassword(String username, String password);

View file

@ -0,0 +1,37 @@
package com.wbrawner.twigs.user;
import com.wbrawner.twigs.session.Session;
import com.wbrawner.twigs.session.UserSessionRepository;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import java.util.Objects;
import static com.wbrawner.twigs.Utils.getCurrentUser;
@Service
public class UserService {
private final DaoAuthenticationProvider authenticationProvider;
private final UserRepository userRepository;
private final UserSessionRepository userSessionRepository;
@Autowired
public UserService(DaoAuthenticationProvider authenticationProvider, UserRepository userRepository, UserSessionRepository userSessionRepository) {
this.authenticationProvider = authenticationProvider;
this.userRepository = userRepository;
this.userSessionRepository = userSessionRepository;
}
public Session login(String username, String password) throws AuthenticationException {
var authReq = new UsernamePasswordAuthenticationToken(username, password);
Authentication auth = authenticationProvider.authenticate(authReq);
SecurityContextHolder.getContext().setAuthentication(auth);
var user = Objects.requireNonNull(getCurrentUser());
return userSessionRepository.save(new Session(user.getId()));
}
}

View file

@ -8,7 +8,7 @@ services:
depends_on:
- db
environment:
SPRING_DATASOURCE_URL: "jdbc:mysql://db:3306/budget?useSSL=false"
SPRING_DATASOURCE_URL: "jdbc:postgres://db:5432/twigs"
SPRING_JPA_HIBERNATE_DDL-AUTO: update
SERVER_TOMCAT_MAX-THREADS: 5
TWIGS_CORS_DOMAINS: "http://localhost:4200"
@ -17,14 +17,13 @@ services:
command: sh -c "sleep 5 && /opt/java/openjdk/bin/java $JVM_ARGS -jar /twigs-api.jar"
db:
image: mysql:5.7
image: postgres:13
ports:
- "3306:3306"
- "5432:5432"
environment:
MYSQL_RANDOM_ROOT_PASSWORD: "yes"
MYSQL_DATABASE: budget
MYSQL_USER: budget
MYSQL_PASSWORD: budget
POSTGRES_DB: twigs
POSTGRES_USER: twigs
POSTGRES_PASSWORD: twigs
networks:
- twigs
hostname: db

View file

@ -1,5 +1,5 @@
#Fri Feb 07 18:11:46 CST 2020
distributionUrl=https\://services.gradle.org/distributions/gradle-6.4-all.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.0.1-all.zip
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStorePath=wrapper/dists

View file

@ -3,4 +3,7 @@
*/
rootProject.name = 'twigs'
include ':api'
include ':api'
include 'app'
include 'core'