Refactored API organization, added functionality for password resets.

This commit is contained in:
Andrew Lalis 2023-02-03 17:05:33 +01:00
parent d60f7142e8
commit 5d18da6ebe
60 changed files with 363 additions and 166 deletions

View File

@ -4,8 +4,18 @@ An HTTP/REST API powered by Java and Spring Boot. This API serves as the main en
## Development ## Development
To get started, follow these steps:
1. Clone the repository, and open this project it in your editor. (Open the pom.xml file if you're using a maven-aware editor).
2. Run `./gen_keys.d` to generate the keys that will be used by this application for signing JWT tokens. (Requires a D lang installation).
3. Make sure the *gymboard-cdn* service is running locally.
4. Boot up the project.
### Sample Data
To ease development, `nl.andrewlalis.gymboard_api.util.SampleDataLoader` will run on startup and populate the database with some sample entities. You can regenerate this data by manually deleting the database, and deleting the `.sample_data` marker file that's generated in the project directory. To ease development, `nl.andrewlalis.gymboard_api.util.SampleDataLoader` will run on startup and populate the database with some sample entities. You can regenerate this data by manually deleting the database, and deleting the `.sample_data` marker file that's generated in the project directory.
You should have the *gymboard-cdn* project running when starting up this API, since the sample data includes videos that will be uploaded as part of some sample submissions.
## ULIDs ## ULIDs
For entities that don't need a human-readable primary key (or keys), we choose to use [ULID](https://github.com/ulid/spec) strings, which are like UUIDs, but use a timestamp based preamble such that their values are monotonically increasing, and lexicographically ordered by creation time. The result is a pseudorandom string of 26 characters which appears random to a human, yet is efficient as a primary key. It's also near-impossible for automated systems to guess previous/next ids. For entities that don't need a human-readable primary key (or keys), we choose to use [ULID](https://github.com/ulid/spec) strings, which are like UUIDs, but use a timestamp based preamble such that their values are monotonically increasing, and lexicographically ordered by creation time. The result is a pseudorandom string of 26 characters which appears random to a human, yet is efficient as a primary key. It's also near-impossible for automated systems to guess previous/next ids.

View File

@ -77,8 +77,20 @@
<dependency> <dependency>
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId> <artifactId>spring-boot-starter-test</artifactId>
<!-- TODO: Change this to "test" once the SampleDataLoader is refactored to the tests. --> <scope>test</scope>
<scope>compile</scope> </dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
<version>5.1.1</version>
<scope>test</scope>
</dependency>
<!-- https://mvnrepository.com/artifact/com.h2database/h2 -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<version>2.1.214</version>
<scope>test</scope>
</dependency> </dependency>
</dependencies> </dependencies>

View File

@ -46,7 +46,8 @@ public class SecurityConfig {
"/exercises", "/exercises",
"/leaderboards", "/leaderboards",
"/gyms/**", "/gyms/**",
"/submissions/**" "/submissions/**",
"/auth/reset-password"
).permitAll() ).permitAll()
.requestMatchers(// Allow the following POST endpoints to be public. .requestMatchers(// Allow the following POST endpoints to be public.
HttpMethod.POST, HttpMethod.POST,
@ -54,7 +55,8 @@ public class SecurityConfig {
"/gyms/*/submissions/upload", "/gyms/*/submissions/upload",
"/auth/token", "/auth/token",
"/auth/register", "/auth/register",
"/auth/activate" "/auth/activate",
"/auth/reset-password"
).permitAll() ).permitAll()
// Everything else must be authenticated, just to be safe. // Everything else must be authenticated, just to be safe.
.anyRequest().authenticated(); .anyRequest().authenticated();

View File

@ -6,9 +6,9 @@ import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication; import nl.andrewlalis.gymboard_api.domains.auth.model.TokenAuthentication;
import nl.andrewlalis.gymboard_api.service.auth.TokenService; import nl.andrewlalis.gymboard_api.domains.auth.service.TokenService;
import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;

View File

@ -1,3 +0,0 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record TokenResponse(String token) {}

View File

@ -1,3 +0,0 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record UserActivationPayload(String code) {}

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.domains.api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseResponse;
import nl.andrewlalis.gymboard_api.service.ExerciseService; import nl.andrewlalis.gymboard_api.domains.api.service.ExerciseService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;

View File

@ -1,11 +1,11 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.domains.api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
import nl.andrewlalis.gymboard_api.service.GymService; import nl.andrewlalis.gymboard_api.domains.api.service.GymService;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
import org.springframework.web.bind.annotation.*; import org.springframework.web.bind.annotation.*;
import java.util.List; import java.util.List;

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.domains.api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.service.LeaderboardService; import nl.andrewlalis.gymboard_api.domains.api.service.LeaderboardService;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.domains.api.controller;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.dao; package nl.andrewlalis.gymboard_api.domains.api.dao;
import nl.andrewlalis.gymboard_api.model.City; import nl.andrewlalis.gymboard_api.domains.api.model.City;
import nl.andrewlalis.gymboard_api.model.CityId; import nl.andrewlalis.gymboard_api.domains.api.model.CityId;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.dao; package nl.andrewlalis.gymboard_api.domains.api.dao;
import nl.andrewlalis.gymboard_api.model.Country; import nl.andrewlalis.gymboard_api.domains.api.model.Country;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;

View File

@ -1,8 +1,8 @@
package nl.andrewlalis.gymboard_api.dao; package nl.andrewlalis.gymboard_api.domains.api.dao;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.model.GymId; import nl.andrewlalis.gymboard_api.domains.api.model.GymId;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.dao.exercise; package nl.andrewlalis.gymboard_api.domains.api.dao.exercise;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.dao.exercise; package nl.andrewlalis.gymboard_api.domains.api.dao.exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
public record ExerciseResponse( public record ExerciseResponse(
String shortName, String shortName,

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
public record ExerciseSubmissionPayload( public record ExerciseSubmissionPayload(
String name, String name,

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
import nl.andrewlalis.gymboard_api.model.GeoPoint; import nl.andrewlalis.gymboard_api.domains.api.model.GeoPoint;
public record GeoPointResponse( public record GeoPointResponse(
double latitude, double latitude,

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.api.dto;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
public record GymSimpleResponse( public record GymSimpleResponse(
String countryCode, String countryCode,

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import jakarta.persistence.*; import jakarta.persistence.*;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Embeddable; import jakarta.persistence.Embeddable;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Embeddable; import jakarta.persistence.Embeddable;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Embeddable; import jakarta.persistence.Embeddable;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model; package nl.andrewlalis.gymboard_api.domains.api.model;
import java.time.Duration; import java.time.Duration;
import java.time.LocalDateTime; import java.time.LocalDateTime;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model.exercise; package nl.andrewlalis.gymboard_api.domains.api.model.exercise;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.model.exercise; package nl.andrewlalis.gymboard_api.domains.api.model.exercise;
import jakarta.persistence.*; import jakarta.persistence.*;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
import java.math.BigDecimal; import java.math.BigDecimal;

View File

@ -1,7 +1,7 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.domains.api.service;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseResponse;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import org.springframework.data.domain.Sort; import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;

View File

@ -1,11 +1,11 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.domains.api.service;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.controller.dto.GymResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.GymResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.util.PredicateBuilder; import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@ -1,13 +1,13 @@
package nl.andrewlalis.gymboard_api.service; package nl.andrewlalis.gymboard_api.domains.api.service;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.model.LeaderboardTimeframe; import nl.andrewlalis.gymboard_api.domains.api.model.LeaderboardTimeframe;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.util.PredicateBuilder; import nl.andrewlalis.gymboard_api.util.PredicateBuilder;
import org.springframework.data.domain.Page; import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Pageable;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.service.cdn_client; package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.ObjectMapper;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.service.cdn_client; package nl.andrewlalis.gymboard_api.domains.api.service.cdn_client;
import java.nio.file.Path; import java.nio.file.Path;

View File

@ -1,14 +1,14 @@
package nl.andrewlalis.gymboard_api.service.submission; package nl.andrewlalis.gymboard_api.domains.api.service.submission;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionResponse; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionResponse;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseSubmissionRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseSubmissionRepository;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.util.ULID; import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;

View File

@ -1,15 +1,13 @@
package nl.andrewlalis.gymboard_api.controller; package nl.andrewlalis.gymboard_api.domains.auth.controller;
import nl.andrewlalis.gymboard_api.controller.dto.*; import nl.andrewlalis.gymboard_api.domains.auth.dto.*;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.service.auth.TokenService; import nl.andrewlalis.gymboard_api.domains.auth.service.TokenService;
import nl.andrewlalis.gymboard_api.service.auth.UserService; import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.*;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
@RestController @RestController
public class AuthController { public class AuthController {
@ -80,4 +78,16 @@ public class AuthController {
public UserResponse getMyUser(@AuthenticationPrincipal User user) { public UserResponse getMyUser(@AuthenticationPrincipal User user) {
return new UserResponse(user); return new UserResponse(user);
} }
@GetMapping(path = "/auth/reset-password")
public ResponseEntity<Void> generatePasswordResetCode(@RequestParam String email) {
userService.generatePasswordResetCode(email);
return ResponseEntity.ok().build();
}
@PostMapping(path = "/auth/reset-password")
public ResponseEntity<Void> resetPassword(@RequestBody PasswordResetPayload payload) {
userService.resetUserPassword(payload);
return ResponseEntity.ok().build();
}
} }

View File

@ -0,0 +1,14 @@
package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.stereotype.Repository;
import java.time.LocalDateTime;
@Repository
public interface PasswordResetCodeRepository extends JpaRepository<PasswordResetCode, String> {
@Modifying
void deleteAllByCreatedAtBefore(LocalDateTime cutoff);
}

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.dao.auth; package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.model.auth.Role; import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.dao.auth; package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode; import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.dao.auth; package nl.andrewlalis.gymboard_api.domains.auth.dao;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Query;

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record PasswordResetPayload(String code, String newPassword) {}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record TokenCredentials( public record TokenCredentials(
String email, String email,

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record TokenResponse(String token) {}

View File

@ -0,0 +1,3 @@
package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record UserActivationPayload(String code) {}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.auth.dto;
public record UserCreationPayload( public record UserCreationPayload(
String email, String email,

View File

@ -1,6 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto; package nl.andrewlalis.gymboard_api.domains.auth.dto;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.domains.auth.model.User;
public record UserResponse( public record UserResponse(
String id, String id,

View File

@ -0,0 +1,39 @@
package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp;
import java.time.LocalDateTime;
@Entity
@Table(name = "auth_user_password_reset_code")
public class PasswordResetCode {
@Id
@Column(nullable = false, updatable = false, length = 127)
private String code;
@CreationTimestamp
private LocalDateTime createdAt;
@ManyToOne(optional = false, fetch = FetchType.LAZY)
private User user;
public PasswordResetCode() {}
public PasswordResetCode(String code, User user) {
this.code = code;
this.user = user;
}
public String getCode() {
return code;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public User getUser() {
return user;
}
}

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model.auth; package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.Column; import jakarta.persistence.Column;
import jakarta.persistence.Entity; import jakarta.persistence.Entity;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model.auth; package nl.andrewlalis.gymboard_api.domains.auth.model;
import org.springframework.security.core.Authentication; import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.GrantedAuthority;

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model.auth; package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;
@ -72,6 +72,10 @@ public class User {
return passwordHash; return passwordHash;
} }
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public String getName() { public String getName() {
return name; return name;
} }

View File

@ -1,4 +1,4 @@
package nl.andrewlalis.gymboard_api.model.auth; package nl.andrewlalis.gymboard_api.domains.auth.model;
import jakarta.persistence.*; import jakarta.persistence.*;
import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.CreationTimestamp;

View File

@ -1,15 +1,15 @@
package nl.andrewlalis.gymboard_api.service.auth; package nl.andrewlalis.gymboard_api.domains.auth.service;
import io.jsonwebtoken.Claims; import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jws; import io.jsonwebtoken.Jws;
import io.jsonwebtoken.Jwts; import io.jsonwebtoken.Jwts;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; import nl.andrewlalis.gymboard_api.domains.auth.dto.TokenCredentials;
import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; import nl.andrewlalis.gymboard_api.domains.auth.dto.TokenResponse;
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.model.auth.Role; import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication; import nl.andrewlalis.gymboard_api.domains.auth.model.TokenAuthentication;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value; import org.springframework.beans.factory.annotation.Value;

View File

@ -1,14 +1,18 @@
package nl.andrewlalis.gymboard_api.service.auth; package nl.andrewlalis.gymboard_api.domains.auth.service;
import jakarta.mail.MessagingException; import jakarta.mail.MessagingException;
import jakarta.mail.internet.MimeMessage; import jakarta.mail.internet.MimeMessage;
import nl.andrewlalis.gymboard_api.controller.dto.UserActivationPayload; import nl.andrewlalis.gymboard_api.domains.auth.dao.PasswordResetCodeRepository;
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; import nl.andrewlalis.gymboard_api.domains.auth.dto.PasswordResetPayload;
import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; import nl.andrewlalis.gymboard_api.domains.auth.dto.UserActivationPayload;
import nl.andrewlalis.gymboard_api.dao.auth.UserActivationCodeRepository; import nl.andrewlalis.gymboard_api.domains.auth.dto.UserCreationPayload;
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.dto.UserResponse;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserActivationCodeRepository;
import nl.andrewlalis.gymboard_api.model.auth.UserActivationCode; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.domains.auth.model.PasswordResetCode;
import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.domains.auth.model.UserActivationCode;
import nl.andrewlalis.gymboard_api.util.StringGenerator;
import nl.andrewlalis.gymboard_api.util.ULID; import nl.andrewlalis.gymboard_api.util.ULID;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
@ -31,6 +35,7 @@ public class UserService {
private final UserRepository userRepository; private final UserRepository userRepository;
private final UserActivationCodeRepository activationCodeRepository; private final UserActivationCodeRepository activationCodeRepository;
private final PasswordResetCodeRepository passwordResetCodeRepository;
private final ULID ulid; private final ULID ulid;
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final JavaMailSender mailSender; private final JavaMailSender mailSender;
@ -40,12 +45,15 @@ public class UserService {
public UserService( public UserService(
UserRepository userRepository, UserRepository userRepository,
UserActivationCodeRepository activationCodeRepository, ULID ulid, UserActivationCodeRepository activationCodeRepository,
PasswordResetCodeRepository passwordResetCodeRepository,
ULID ulid,
PasswordEncoder passwordEncoder, PasswordEncoder passwordEncoder,
JavaMailSender mailSender JavaMailSender mailSender
) { ) {
this.userRepository = userRepository; this.userRepository = userRepository;
this.activationCodeRepository = activationCodeRepository; this.activationCodeRepository = activationCodeRepository;
this.passwordResetCodeRepository = passwordResetCodeRepository;
this.ulid = ulid; this.ulid = ulid;
this.passwordEncoder = passwordEncoder; this.passwordEncoder = passwordEncoder;
this.mailSender = mailSender; this.mailSender = mailSender;
@ -117,13 +125,74 @@ public class UserService {
public UserResponse activateUser(UserActivationPayload payload) { public UserResponse activateUser(UserActivationPayload payload) {
UserActivationCode activationCode = activationCodeRepository.findByCode(payload.code()) UserActivationCode activationCode = activationCodeRepository.findByCode(payload.code())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
LocalDateTime cutoff = LocalDateTime.now().minusDays(1);
if (activationCode.getCreatedAt().isBefore(cutoff)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired.");
}
User user = activationCode.getUser(); User user = activationCode.getUser();
user.setActivated(true); if (!user.isActivated()) {
userRepository.save(user); LocalDateTime cutoff = LocalDateTime.now().minusDays(1);
if (activationCode.getCreatedAt().isBefore(cutoff)) {
throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "Code is expired.");
}
user.setActivated(true);
userRepository.save(user);
}
activationCodeRepository.delete(activationCode);
return new UserResponse(user); return new UserResponse(user);
} }
@Transactional
public void generatePasswordResetCode(String email) {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
if (!user.isActivated()) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
PasswordResetCode passwordResetCode = passwordResetCodeRepository.save(new PasswordResetCode(
StringGenerator.randomString(127, StringGenerator.Alphabet.ALPHANUMERIC),
user
));
// Send email.
String resetLink = webOrigin + "/password-reset?code=" + passwordResetCode.getCode();
String emailContent = String.format(
"""
<p>Hello %s,</p>
<p>
You've just requested to reset your password.
</p>
<p>
Please click <a href="%s">here</a> to reset your password.
</p>
""",
user.getName(),
resetLink
);
MimeMessage msg = mailSender.createMimeMessage();
try {
MimeMessageHelper helper = new MimeMessageHelper(msg, "UTF-8");
helper.setFrom("Gymboard <noreply@gymboard.io>");
helper.setSubject("Gymboard Account Password Reset");
helper.setTo(user.getEmail());
helper.setText(emailContent, true);
mailSender.send(msg);
} catch (MessagingException e) {
log.error("Error sending user password reset email.", e);
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR);
}
}
@Transactional
public void resetUserPassword(PasswordResetPayload payload) {
PasswordResetCode code = passwordResetCodeRepository.findById(payload.code())
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
LocalDateTime cutoff = LocalDateTime.now().minusMinutes(30);
if (code.getCreatedAt().isBefore(cutoff)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND);
}
// TODO: Validate password.
code.getUser().setPasswordHash(passwordEncoder.encode(payload.newPassword()));
passwordResetCodeRepository.delete(code);
}
} }

View File

@ -1,25 +1,25 @@
package nl.andrewlalis.gymboard_api.util; package nl.andrewlalis.gymboard_api.util;
import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.domains.api.dto.CompoundGymId;
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; import nl.andrewlalis.gymboard_api.domains.api.dto.ExerciseSubmissionPayload;
import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; import nl.andrewlalis.gymboard_api.domains.auth.dto.UserCreationPayload;
import nl.andrewlalis.gymboard_api.dao.CityRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.CityRepository;
import nl.andrewlalis.gymboard_api.dao.CountryRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.CountryRepository;
import nl.andrewlalis.gymboard_api.dao.GymRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.GymRepository;
import nl.andrewlalis.gymboard_api.dao.auth.RoleRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.RoleRepository;
import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.domains.auth.dao.UserRepository;
import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; import nl.andrewlalis.gymboard_api.domains.api.dao.exercise.ExerciseRepository;
import nl.andrewlalis.gymboard_api.model.City; import nl.andrewlalis.gymboard_api.domains.api.model.City;
import nl.andrewlalis.gymboard_api.model.Country; import nl.andrewlalis.gymboard_api.domains.api.model.Country;
import nl.andrewlalis.gymboard_api.model.GeoPoint; import nl.andrewlalis.gymboard_api.domains.api.model.GeoPoint;
import nl.andrewlalis.gymboard_api.model.Gym; import nl.andrewlalis.gymboard_api.domains.api.model.Gym;
import nl.andrewlalis.gymboard_api.model.auth.Role; import nl.andrewlalis.gymboard_api.domains.auth.model.Role;
import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.domains.auth.model.User;
import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.Exercise;
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; import nl.andrewlalis.gymboard_api.domains.api.model.exercise.ExerciseSubmission;
import nl.andrewlalis.gymboard_api.service.auth.UserService; import nl.andrewlalis.gymboard_api.domains.auth.service.UserService;
import nl.andrewlalis.gymboard_api.service.cdn_client.CdnClient; import nl.andrewlalis.gymboard_api.domains.api.service.cdn_client.CdnClient;
import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.domains.api.service.submission.ExerciseSubmissionService;
import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVFormat;
import org.apache.commons.csv.CSVRecord; import org.apache.commons.csv.CSVRecord;
import org.slf4j.Logger; import org.slf4j.Logger;

View File

@ -0,0 +1,25 @@
package nl.andrewlalis.gymboard_api.util;
import java.security.SecureRandom;
import java.util.Random;
public class StringGenerator {
public enum Alphabet {
ALPHANUMERIC("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789");
public final String value;
Alphabet(String value) {
this.value = value;
}
}
public static String randomString(int length, Alphabet alphabet) {
Random random = new SecureRandom();
StringBuilder sb = new StringBuilder(length);
for (int i = 0; i < length; i++) {
sb.append(alphabet.value.charAt(random.nextInt(alphabet.value.length())));
}
return sb.toString();
}
}

View File

@ -1,6 +1 @@
spring.jpa.open-in-view=false spring.jpa.open-in-view=false
# TODO: Find a better way than dumping files into memory.
spring.servlet.multipart.enabled=true
spring.servlet.multipart.max-file-size=1GB
spring.servlet.multipart.max-request-size=2GB

View File

@ -5,7 +5,6 @@ import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest @SpringBootTest
class GymboardApiApplicationTests { class GymboardApiApplicationTests {
@Test @Test
void contextLoads() { void contextLoads() {
} }

View File

@ -0,0 +1,15 @@
spring.datasource.url=jdbc:h2:mem:db;DB_CLOSE_DELAY=-1
spring.datasource.driver-class-name=org.h2.Driver
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=update
spring.mail.host=127.0.0.1
spring.mail.port=1025
spring.mail.protocol=smtp
spring.mail.properties.mail.smtp.timeout=10000
app.auth.private-key-location=./private_key.der
app.web-origin=http://localhost:9000
app.cdn-origin=http://localhost:8082