From 1034f7e65a2d46b0ada9f5220213f044cf88b4ab Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Mon, 30 Jan 2023 08:18:09 +0100 Subject: [PATCH 1/5] Removed unused import, changed to save and flush submission during processing. --- .../andrewlalis/gymboard_api/controller/GymController.java | 1 - .../service/submission/SubmissionProcessingService.java | 6 +++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java index 41dc25f..351fa94 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/GymController.java @@ -2,7 +2,6 @@ package nl.andrewlalis.gymboard_api.controller; import nl.andrewlalis.gymboard_api.controller.dto.*; import nl.andrewlalis.gymboard_api.service.GymService; -import nl.andrewlalis.gymboard_api.service.LeaderboardService; import nl.andrewlalis.gymboard_api.service.UploadService; import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import org.springframework.http.MediaType; diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/submission/SubmissionProcessingService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/submission/SubmissionProcessingService.java index 200a639..8690662 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/submission/SubmissionProcessingService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/submission/SubmissionProcessingService.java @@ -90,7 +90,7 @@ public class SubmissionProcessingService { // Set the status to processing. submission.setStatus(ExerciseSubmission.Status.PROCESSING); - exerciseSubmissionRepository.save(submission); + exerciseSubmissionRepository.saveAndFlush(submission); // Then try and fetch the temporary video file associated with it. Optional optionalTempFile = tempFileRepository.findBySubmission(submission); @@ -105,7 +105,7 @@ public class SubmissionProcessingService { if (!Files.exists(tempFilePath) || !Files.isReadable(tempFilePath)) { log.error("Submission {} failed because the temporary video file {} isn't readable.", submission.getId(), tempFilePath); submission.setStatus(ExerciseSubmission.Status.FAILED); - exerciseSubmissionRepository.save(submission); + exerciseSubmissionRepository.saveAndFlush(submission); return; } @@ -135,7 +135,7 @@ public class SubmissionProcessingService { e.getMessage() ); submission.setStatus(ExerciseSubmission.Status.FAILED); - exerciseSubmissionRepository.save(submission); + exerciseSubmissionRepository.saveAndFlush(submission); return; } From 0e96d74203ae0079a8dad4e24e185b6ffc165f76 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Mon, 30 Jan 2023 10:07:28 +0100 Subject: [PATCH 2/5] Added users! --- gymboard-api/.gitignore | 3 + gymboard-api/gen_keys.d | 49 ++++++++ gymboard-api/pom.xml | 27 +++++ gymboard-api/sample_data/users.csv | 4 + .../config/SecurityComponents.java | 14 +++ .../gymboard_api/config/SecurityConfig.java | 38 +++++++ .../config/TokenAuthenticationFilter.java | 38 +++++++ .../controller/AuthController.java | 22 ++++ .../controller/dto/TokenCredentials.java | 6 + .../controller/dto/TokenResponse.java | 3 + .../controller/dto/UserCreationPayload.java | 7 ++ .../controller/dto/UserResponse.java | 19 ++++ .../gymboard_api/dao/auth/RoleRepository.java | 9 ++ .../gymboard_api/dao/auth/UserRepository.java | 21 ++++ .../gymboard_api/model/SampleDataLoader.java | 36 +++++- .../gymboard_api/model/auth/Role.java | 24 ++++ .../model/auth/TokenAuthentication.java | 47 ++++++++ .../gymboard_api/model/auth/User.java | 78 +++++++++++++ .../service/auth/TokenService.java | 105 ++++++++++++++++++ .../service/auth/UserService.java | 45 ++++++++ .../application-development.properties | 3 + 21 files changed, 596 insertions(+), 2 deletions(-) create mode 100755 gymboard-api/gen_keys.d create mode 100644 gymboard-api/sample_data/users.csv create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java diff --git a/gymboard-api/.gitignore b/gymboard-api/.gitignore index 02a81a7..71753f8 100644 --- a/gymboard-api/.gitignore +++ b/gymboard-api/.gitignore @@ -33,3 +33,6 @@ build/ .sample_data exercise_submission_temp_files/ +private_key.der +private_key.pem +public_key.der diff --git a/gymboard-api/gen_keys.d b/gymboard-api/gen_keys.d new file mode 100755 index 0000000..81376d6 --- /dev/null +++ b/gymboard-api/gen_keys.d @@ -0,0 +1,49 @@ +#!/usr/bin/env rdmd +/** + * A simple script that generates the keys needed by DigiOPP Auth for signing + * tokens and other cryptographic needs. Use this in your development + * environment to ensure that your local auth project can issue keys to other + * development services. + * + * Authors: Andrew Lalis + */ +module gen_keys; + +import std.stdio; +import std.file; + +const privateKeyFile = "private_key.pem"; +const privateKeyDerFile = "private_key.der"; +const publicKeyDerFile = "public_key.der"; + +const cmdGenRSAPrivateKey = "openssl genrsa -out private_key.pem 2048"; +const cmdGenDERPrivateKey = "openssl pkcs8 -topk8 -inform PEM -outform DER -in private_key.pem -out private_key.der -nocrypt"; +const cmdGenDERPublicKey = "openssl rsa -in private_key.pem -pubout -outform DER -out public_key.der"; + +void removeIfExists(string[] files...) { + foreach (f; files) if (exists(f)) remove(f); +} + +void genKeys(string[] files...) { + import std.process : executeShell; + import std.algorithm : canFind; + if (canFind(files, privateKeyFile)) executeShell(cmdGenRSAPrivateKey); + if (canFind(files, privateKeyDerFile)) executeShell(cmdGenDERPrivateKey); + if (canFind(files, publicKeyDerFile)) executeShell(cmdGenDERPublicKey); +} + +void main() { + if (!exists(privateKeyFile)) { + writeln("No RSA private key found. Regenerating all key files."); + removeIfExists(privateKeyDerFile, publicKeyDerFile); + genKeys(privateKeyFile, privateKeyDerFile, publicKeyDerFile); + } else if (!exists(privateKeyDerFile)) { + writeln("No DER private key found. Regenerating private and public DER files."); + removeIfExists(privateKeyDerFile, publicKeyDerFile); + genKeys(privateKeyDerFile, publicKeyDerFile); + } else if (!exists(publicKeyDerFile)) { + writeln("No DER public key found. Regenerating it."); + genKeys(publicKeyDerFile); + } + writeln("All keys are now generated."); +} diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml index c1e6970..60f0eb6 100644 --- a/gymboard-api/pom.xml +++ b/gymboard-api/pom.xml @@ -21,6 +21,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot spring-boot-starter-validation @@ -29,6 +33,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + org.postgresql @@ -41,6 +49,25 @@ 1.9.0 + + + io.jsonwebtoken + jjwt-api + 0.11.2 + + + io.jsonwebtoken + jjwt-impl + 0.11.2 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.2 + runtime + + org.springframework.boot diff --git a/gymboard-api/sample_data/users.csv b/gymboard-api/sample_data/users.csv new file mode 100644 index 0000000..473acfb --- /dev/null +++ b/gymboard-api/sample_data/users.csv @@ -0,0 +1,4 @@ +jay.cutler@example.com,testpass,Jay Cutler, +mike.mentzer@example.com,testpass,Mike Mentzer, +ronnie.coleman@example.com,testpass,Ronnie 'Lightweight' Coleman, +andrew.lalis@example.com,testpass,Andrew Lalis,admin \ No newline at end of file diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java new file mode 100644 index 0000000..5af3e06 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java @@ -0,0 +1,14 @@ +package nl.andrewlalis.gymboard_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityComponents { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(10); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java new file mode 100644 index 0000000..59cf65f --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package nl.andrewlalis.gymboard_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + private final TokenAuthenticationFilter tokenAuthenticationFilter; + + public SecurityConfig(TokenAuthenticationFilter tokenAuthenticationFilter) { + this.tokenAuthenticationFilter = tokenAuthenticationFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .httpBasic().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() + .csrf().disable() + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests().anyRequest().permitAll(); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager() { + return null;// Disable the standard spring authentication manager. + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java new file mode 100644 index 0000000..caa1615 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java @@ -0,0 +1,38 @@ +package nl.andrewlalis.gymboard_api.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; +import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication; +import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + private final TokenService tokenService; + private final UserRepository userRepository; + + public TokenAuthenticationFilter(TokenService tokenService, UserRepository userRepository) { + this.tokenService = tokenService; + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = tokenService.extractBearerToken(request); + Jws jws = tokenService.getToken(token); + if (jws != null) { + userRepository.findByIdWithRoles(jws.getBody().getSubject()) + .ifPresent(user -> SecurityContextHolder.getContext().setAuthentication(new TokenAuthentication(user, token))); + } + filterChain.doFilter(request, response); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java new file mode 100644 index 0000000..52901e0 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java @@ -0,0 +1,22 @@ +package nl.andrewlalis.gymboard_api.controller; + +import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; +import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; +import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AuthController { + private final TokenService tokenService; + + public AuthController(TokenService tokenService) { + this.tokenService = tokenService; + } + + @PostMapping(path = "/auth/token") + public TokenResponse getToken(@RequestBody TokenCredentials credentials) { + return tokenService.generateAccessToken(credentials); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java new file mode 100644 index 0000000..5637abb --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java @@ -0,0 +1,6 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record TokenCredentials( + String email, + String password +) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java new file mode 100644 index 0000000..99aef46 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java @@ -0,0 +1,3 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record TokenResponse(String token) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java new file mode 100644 index 0000000..4e8446f --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java @@ -0,0 +1,7 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record UserCreationPayload( + String email, + String password, + String name +) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java new file mode 100644 index 0000000..592ddd9 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java @@ -0,0 +1,19 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +import nl.andrewlalis.gymboard_api.model.auth.User; + +public record UserResponse( + String id, + boolean activated, + String email, + String name +) { + public UserResponse(User user) { + this( + user.getId(), + user.isActivated(), + user.getEmail(), + user.getName() + ); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java new file mode 100644 index 0000000..50a2b5d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java @@ -0,0 +1,9 @@ +package nl.andrewlalis.gymboard_api.dao.auth; + +import nl.andrewlalis.gymboard_api.model.auth.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RoleRepository extends JpaRepository { +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java new file mode 100644 index 0000000..31e7d58 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java @@ -0,0 +1,21 @@ +package nl.andrewlalis.gymboard_api.dao.auth; + +import nl.andrewlalis.gymboard_api.model.auth.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { + boolean existsByEmail(String email); + Optional findByEmail(String email); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id") + Optional findByIdWithRoles(String id); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email") + Optional findByEmailWithRoles(String email); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java index 6b56140..907ef7f 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java @@ -2,14 +2,20 @@ package nl.andrewlalis.gymboard_api.model; import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; +import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; import nl.andrewlalis.gymboard_api.dao.CityRepository; import nl.andrewlalis.gymboard_api.dao.CountryRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository; +import nl.andrewlalis.gymboard_api.dao.auth.RoleRepository; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; +import nl.andrewlalis.gymboard_api.model.auth.Role; +import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; -import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.service.UploadService; +import nl.andrewlalis.gymboard_api.service.auth.UserService; +import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVRecord; import org.slf4j.Logger; @@ -39,19 +45,27 @@ public class SampleDataLoader implements ApplicationListener { + String email = record.get(0); + String password = record.get(1); + String name = record.get(2); + String[] roleNames = record.get(3).split("\\s*\\|\\s*"); + + UserCreationPayload payload = new UserCreationPayload(email, password, name); + var resp = userService.createUser(payload); + User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow(); + for (var roleName : roleNames) { + if (roleName.isBlank()) continue; + Role role = roleRepository.findById(roleName.strip().toLowerCase()) + .orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase()))); + user.getRoles().add(role); + } + userRepository.save(user); + }); } private void loadCsv(String csvName, Consumer recordConsumer) throws IOException { diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java new file mode 100644 index 0000000..4c9dea8 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java @@ -0,0 +1,24 @@ +package nl.andrewlalis.gymboard_api.model.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "auth_role") +public class Role { + @Id + @Column(nullable = false) + private String shortName; + + public Role() {} + + public Role(String shortName) { + this.shortName = shortName; + } + + public String getShortName() { + return shortName; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java new file mode 100644 index 0000000..8860369 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java @@ -0,0 +1,47 @@ +package nl.andrewlalis.gymboard_api.model.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.Collections; + +public record TokenAuthentication( + User user, + String token +) implements Authentication { + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public Object getCredentials() { + return token; + } + + @Override + public Object getDetails() { + return null; + } + + @Override + public Object getPrincipal() { + return user; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { + // Not allowed. + } + + @Override + public String getName() { + return user.getEmail(); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java new file mode 100644 index 0000000..13c948d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java @@ -0,0 +1,78 @@ +package nl.andrewlalis.gymboard_api.model.auth; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "auth_user") +public class User { + @Id + @Column(nullable = false, updatable = false, length = 26) + private String id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Column(nullable = false) + private boolean activated; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String passwordHash; + + @Column(nullable = false) + private String name; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "auth_user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_short_name") + ) + private Set roles; + + public User() {} + + public User(String id, boolean activated, String email, String passwordHash, String name) { + this.id = id; + this.activated = activated; + this.email = email; + this.passwordHash = passwordHash; + this.name = name; + this.roles = new HashSet<>(); + } + + public String getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public boolean isActivated() { + return activated; + } + + public String getEmail() { + return email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public String getName() { + return name; + } + + public Set getRoles() { + return roles; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java new file mode 100644 index 0000000..038bc7d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java @@ -0,0 +1,105 @@ +package nl.andrewlalis.gymboard_api.service.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; +import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; +import nl.andrewlalis.gymboard_api.model.auth.Role; +import nl.andrewlalis.gymboard_api.model.auth.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.stream.Collectors; + +@Service +public class TokenService { + private static final Logger log = LoggerFactory.getLogger(TokenService.class); + private static final String BEARER_PREFIX = "Bearer "; + private static final String ISSUER = "Gymboard"; + private PrivateKey privateKey = null; + + @Value("${app.auth.private-key-location}") + private String privateKeyLocation; + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public TokenService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public String generateAccessToken(User user) { + Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES); + return Jwts.builder() + .setSubject(user.getId()) + .setIssuer(ISSUER) + .setAudience("Gymboard App") + .setExpiration(Date.from(expiration)) + .claim("email", user.getEmail()) + .claim("name", user.getName()) + .claim("roles", user.getRoles().stream() + .map(Role::getShortName) + .collect(Collectors.joining(","))) + .signWith(getPrivateKey()) + .compact(); + } + + public TokenResponse generateAccessToken(TokenCredentials credentials) { + User user = userRepository.findByEmailWithRoles(credentials.email()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + if (!passwordEncoder.matches(credentials.password(), user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + String token = generateAccessToken(user); + return new TokenResponse(token); + } + + public String extractBearerToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader == null || authorizationHeader.isBlank()) return null; + if (authorizationHeader.startsWith(BEARER_PREFIX)) { + return authorizationHeader.substring(BEARER_PREFIX.length()); + } + return null; + } + + public Jws getToken(String token) { + if (token == null) return null; + var builder = Jwts.parserBuilder() + .setSigningKey(this.getPrivateKey()) + .requireIssuer(ISSUER); + return builder.build().parseClaimsJws(token); + } + + private PrivateKey getPrivateKey() { + if (privateKey == null) { + try { + byte[] keyBytes = Files.readAllBytes(Path.of(this.privateKeyLocation)); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory kf = KeyFactory.getInstance("RSA"); + privateKey = kf.generatePrivate(spec); + } catch (Exception e) { + log.error("Could not obtain private key.", e); + throw new RuntimeException("Cannot obtain private key.", e); + } + } + return privateKey; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java new file mode 100644 index 0000000..32df224 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java @@ -0,0 +1,45 @@ +package nl.andrewlalis.gymboard_api.service.auth; + +import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; +import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; +import nl.andrewlalis.gymboard_api.model.auth.User; +import nl.andrewlalis.gymboard_api.util.ULID; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class UserService { + private final UserRepository userRepository; + private final ULID ulid; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, ULID ulid, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.ulid = ulid; + this.passwordEncoder = passwordEncoder; + } + + @Transactional(readOnly = true) + public UserResponse getUser(String id) { + User user = userRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + return new UserResponse(user); + } + + @Transactional + public UserResponse createUser(UserCreationPayload payload) { + // TODO: Validate user payload. + User user = userRepository.save(new User( + ulid.nextULID(), + true, // TODO: Change this to false once email activation is in. + payload.email(), + passwordEncoder.encode(payload.password()), + payload.name() + )); + return new UserResponse(user); + } +} diff --git a/gymboard-api/src/main/resources/application-development.properties b/gymboard-api/src/main/resources/application-development.properties index 13bdea4..974c949 100644 --- a/gymboard-api/src/main/resources/application-development.properties +++ b/gymboard-api/src/main/resources/application-development.properties @@ -8,3 +8,6 @@ spring.jpa.show-sql=false spring.task.execution.pool.core-size=3 spring.task.execution.pool.max-size=10 + +app.auth.private-key-location=./private_key.der + From f8dcfc8843287ac123405b69063ca11e95d17aff Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Mon, 30 Jan 2023 11:55:09 +0100 Subject: [PATCH 3/5] Added auth stuff to the web app. --- .../gymboard_api/config/SecurityConfig.java | 31 +++++++++++++++ .../gymboard_api/config/WebConfig.java | 20 ---------- .../controller/AuthController.java | 11 ++++++ gymboard-app/src/api/main/auth.ts | 30 +++++++++++++++ gymboard-app/src/api/main/index.ts | 2 + gymboard-app/src/layouts/MainLayout.vue | 3 +- gymboard-app/src/pages/TestingPage.vue | 38 +++++++++++++++++++ gymboard-app/src/router/routes.ts | 2 + 8 files changed, 116 insertions(+), 21 deletions(-) create mode 100644 gymboard-app/src/api/main/auth.ts create mode 100644 gymboard-app/src/pages/TestingPage.vue diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 59cf65f..6d016d8 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -2,6 +2,7 @@ package nl.andrewlalis.gymboard_api.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -9,6 +10,9 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration @EnableWebSecurity @@ -20,17 +24,44 @@ public class SecurityConfig { this.tokenAuthenticationFilter = tokenAuthenticationFilter; } + /** + * Defines the security configuration we'll use for this API. + * @param http The security configurable. + * @return The filter chain to apply. + * @throws Exception If an error occurs while configuring. + */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .httpBasic().disable() .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() .csrf().disable() + .cors().and() .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .authorizeHttpRequests().anyRequest().permitAll(); return http.build(); } + /** + * Defines the CORS configuration for this API, which is to say that we + * allow cross-origin requests ONLY from the web app for the vast majority + * of endpoints. + * @return The CORS configuration source. + */ + @Bean + @Order(1) + public CorsConfigurationSource corsConfigurationSource() { + final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + final CorsConfiguration config = new CorsConfiguration(); + config.setAllowCredentials(true); + // Don't do this in production, use a proper list of allowed origins + config.addAllowedOriginPattern("*"); + config.addAllowedHeader("*"); + config.addAllowedMethod("*"); + source.registerCorsConfiguration("/**", config); + return source; + } + @Bean public AuthenticationManager authenticationManager() { return null;// Disable the standard spring authentication manager. diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java index e1bbd40..f78f351 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java @@ -1,31 +1,11 @@ package nl.andrewlalis.gymboard_api.config; import nl.andrewlalis.gymboard_api.util.ULID; -import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.web.cors.CorsConfiguration; -import org.springframework.web.cors.UrlBasedCorsConfigurationSource; -import org.springframework.web.filter.CorsFilter; - -import java.time.OffsetDateTime; -import java.util.Arrays; @Configuration public class WebConfig { - @Bean - public CorsFilter corsFilter() { - final UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); - final CorsConfiguration config = new CorsConfiguration(); - config.setAllowCredentials(true); - // Don't do this in production, use a proper list of allowed origins - config.addAllowedOriginPattern("*"); - config.setAllowedHeaders(Arrays.asList("Origin", "Content-Type", "Accept")); - config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "OPTIONS", "DELETE", "PATCH")); - source.registerCorsConfiguration("/**", config); - return new CorsFilter(source); - } - @Bean public ULID ulid() { return new ULID(); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java index 52901e0..46b8e43 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java @@ -2,7 +2,12 @@ package nl.andrewlalis.gymboard_api.controller; import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; +import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; +import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; @@ -19,4 +24,10 @@ public class AuthController { public TokenResponse getToken(@RequestBody TokenCredentials credentials) { return tokenService.generateAccessToken(credentials); } + + @GetMapping(path = "/auth/me") + @PreAuthorize("isAuthenticated()") + public UserResponse getMyUser(@AuthenticationPrincipal User user) { + return new UserResponse(user); + } } diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts new file mode 100644 index 0000000..a21eb80 --- /dev/null +++ b/gymboard-app/src/api/main/auth.ts @@ -0,0 +1,30 @@ +import { api } from 'src/api/main/index'; + +export interface User { + id: string; + activated: boolean; + email: string; + name: string; +} + +export interface TokenCredentials { + email: string; + password: string; +} + +class AuthModule { + public async getToken(credentials: TokenCredentials): Promise { + const response = await api.post('/auth/token', credentials); + return response.data.token; + } + public async getMyUser(token: string): Promise { + const response = await api.get('/auth/me', { + headers: { + 'Authorization': 'Bearer ' + token + } + }); + return response.data; + } +} + +export default AuthModule; diff --git a/gymboard-app/src/api/main/index.ts b/gymboard-app/src/api/main/index.ts index d8c4158..2255b8d 100644 --- a/gymboard-app/src/api/main/index.ts +++ b/gymboard-app/src/api/main/index.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import GymsModule from 'src/api/main/gyms'; import ExercisesModule from 'src/api/main/exercises'; import LeaderboardsModule from 'src/api/main/leaderboards'; +import AuthModule from 'src/api/main/auth'; export const BASE_URL = 'http://localhost:8080'; @@ -11,6 +12,7 @@ export const api = axios.create({ }); class GymboardApi { + public readonly auth = new AuthModule(); public readonly gyms = new GymsModule(); public readonly exercises = new ExercisesModule(); public readonly leaderboards = new LeaderboardsModule(); diff --git a/gymboard-app/src/layouts/MainLayout.vue b/gymboard-app/src/layouts/MainLayout.vue index 6864950..d842419 100644 --- a/gymboard-app/src/layouts/MainLayout.vue +++ b/gymboard-app/src/layouts/MainLayout.vue @@ -41,8 +41,9 @@ {{ $t('mainLayout.pages') }} - Gyms + Gyms Global Leaderboard + Testing Page diff --git a/gymboard-app/src/pages/TestingPage.vue b/gymboard-app/src/pages/TestingPage.vue new file mode 100644 index 0000000..b9eca7f --- /dev/null +++ b/gymboard-app/src/pages/TestingPage.vue @@ -0,0 +1,38 @@ + + + + + diff --git a/gymboard-app/src/router/routes.ts b/gymboard-app/src/router/routes.ts index b77e582..d11c0e2 100644 --- a/gymboard-app/src/router/routes.ts +++ b/gymboard-app/src/router/routes.ts @@ -5,6 +5,7 @@ import GymPage from 'pages/gym/GymPage.vue'; import GymSubmissionPage from 'pages/gym/GymSubmissionPage.vue'; import GymHomePage from 'pages/gym/GymHomePage.vue'; import GymLeaderboardsPage from 'pages/gym/GymLeaderboardsPage.vue'; +import TestingPage from 'pages/TestingPage.vue'; const routes: RouteRecordRaw[] = [ { @@ -12,6 +13,7 @@ const routes: RouteRecordRaw[] = [ component: MainLayout, children: [ { path: '', component: IndexPage }, + { path: 'testing', component: TestingPage }, { path: 'gyms/:countryCode/:cityShortName/:gymShortName', component: GymPage, From 4293ddb1571154bec7acf70d4c0f7d203399c785 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Mon, 30 Jan 2023 14:08:02 +0100 Subject: [PATCH 4/5] Added explicitly defined endpoint security. --- .../gymboard_api/config/SecurityConfig.java | 18 +++++++++++- ...rityComponents.java => WebComponents.java} | 2 +- .../controller/AuthController.java | 29 +++++++++++++++++-- .../service/auth/TokenService.java | 18 ++++++++++++ gymboard-app/src/pages/TestingPage.vue | 2 +- 5 files changed, 64 insertions(+), 5 deletions(-) rename gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/{SecurityComponents.java => WebComponents.java} (92%) diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java index 6d016d8..2e79713 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -3,6 +3,7 @@ package nl.andrewlalis.gymboard_api.config; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.core.annotation.Order; +import org.springframework.http.HttpMethod; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -38,7 +39,22 @@ public class SecurityConfig { .csrf().disable() .cors().and() .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .authorizeHttpRequests().anyRequest().permitAll(); + .authorizeHttpRequests() + .requestMatchers(// Allow the following GET endpoints to be public. + HttpMethod.GET, + "/exercises", + "/leaderboards", + "/gyms/**", + "/submissions/**" + ).permitAll() + .requestMatchers(// Allow the following POST endpoints to be public. + HttpMethod.POST, + "/gyms/submissions", + "/gyms/submissions/upload", + "/auth/token" + ).permitAll() + // Everything else must be authenticated, just to be safe. + .anyRequest().authenticated(); return http.build(); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java similarity index 92% rename from gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java rename to gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java index 5af3e06..6ec6652 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebComponents.java @@ -6,7 +6,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @Configuration -public class SecurityComponents { +public class WebComponents { @Bean public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(10); diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java index 46b8e43..da03fe3 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java @@ -5,7 +5,7 @@ import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.service.auth.TokenService; -import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; @@ -20,13 +20,38 @@ public class AuthController { this.tokenService = tokenService; } + /** + * Endpoint for obtaining a new access token for a user to access certain + * parts of the application. This is a public endpoint. If + * a token is successfully obtained, it should be provided to all subsequent + * requests as an Authorization "Bearer" token. + * @param credentials The credentials. + * @return The token the client should use. + */ @PostMapping(path = "/auth/token") public TokenResponse getToken(@RequestBody TokenCredentials credentials) { return tokenService.generateAccessToken(credentials); } + /** + * Endpoint that can be used by an authenticated user to fetch a new access + * token using their current one; useful for staying logged in beyond the + * duration of the initial token's expiration. + * @param auth The current authentication. + * @return The new token the client should use. + */ + @GetMapping(path = "/auth/token") + public TokenResponse getUpdatedToken(Authentication auth) { + return tokenService.regenerateAccessToken(auth); + } + + /** + * Gets information about the user, as determined by the provided access + * token. + * @param user The user that requested information. + * @return The user data. + */ @GetMapping(path = "/auth/me") - @PreAuthorize("isAuthenticated()") public UserResponse getMyUser(@AuthenticationPrincipal User user) { return new UserResponse(user); } diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java index 038bc7d..6556d2b 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java @@ -8,11 +8,13 @@ import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.model.auth.Role; +import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication; import nl.andrewlalis.gymboard_api.model.auth.User; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; +import org.springframework.security.core.Authentication; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -67,6 +69,22 @@ public class TokenService { if (!passwordEncoder.matches(credentials.password(), user.getPasswordHash())) { throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); } + if (!user.isActivated()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + String token = generateAccessToken(user); + return new TokenResponse(token); + } + + public TokenResponse regenerateAccessToken(Authentication auth) { + if (!(auth instanceof TokenAuthentication tokenAuth)) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + User user = userRepository.findByIdWithRoles(tokenAuth.user().getId()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + if (!user.isActivated()) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } String token = generateAccessToken(user); return new TokenResponse(token); } diff --git a/gymboard-app/src/pages/TestingPage.vue b/gymboard-app/src/pages/TestingPage.vue index b9eca7f..fe97c9b 100644 --- a/gymboard-app/src/pages/TestingPage.vue +++ b/gymboard-app/src/pages/TestingPage.vue @@ -5,7 +5,7 @@ Use this page to test new functionality, before adding it to the main app. This page should be hidden on production.

-
+

Auth Test

Date: Mon, 30 Jan 2023 15:57:11 +0100 Subject: [PATCH 5/5] Added auth management, and AccountMenuItem.vue --- gymboard-app/quasar.extensions.json | 2 +- gymboard-app/src/api/main/auth.ts | 38 +++++++++-- gymboard-app/src/api/main/gyms.ts | 8 ++- gymboard-app/src/api/main/leaderboards.ts | 68 ++++++++++--------- gymboard-app/src/api/main/submission.ts | 21 +++--- .../src/components/AccountMenuItem.vue | 36 ++++++++++ .../components/ExerciseSubmissionListItem.vue | 14 +++- gymboard-app/src/components/LocaleSelect.vue | 31 +++++++++ gymboard-app/src/i18n/en-US/index.ts | 2 +- gymboard-app/src/i18n/nl-NL/index.ts | 2 +- gymboard-app/src/layouts/MainLayout.vue | 28 ++------ gymboard-app/src/pages/TestingPage.vue | 29 +++----- gymboard-app/src/pages/gym/GymHomePage.vue | 53 ++++++++++----- .../src/pages/gym/GymLeaderboardsPage.vue | 22 +++--- .../src/pages/gym/GymSubmissionPage.vue | 9 +-- gymboard-app/src/router/gym-routing.ts | 2 +- gymboard-app/src/stores/auth-store.ts | 34 ++++++++++ 17 files changed, 266 insertions(+), 133 deletions(-) create mode 100644 gymboard-app/src/components/AccountMenuItem.vue create mode 100644 gymboard-app/src/components/LocaleSelect.vue create mode 100644 gymboard-app/src/stores/auth-store.ts diff --git a/gymboard-app/quasar.extensions.json b/gymboard-app/quasar.extensions.json index 9e26dfe..0967ef4 100644 --- a/gymboard-app/quasar.extensions.json +++ b/gymboard-app/quasar.extensions.json @@ -1 +1 @@ -{} \ No newline at end of file +{} diff --git a/gymboard-app/src/api/main/auth.ts b/gymboard-app/src/api/main/auth.ts index a21eb80..7f3e6c3 100644 --- a/gymboard-app/src/api/main/auth.ts +++ b/gymboard-app/src/api/main/auth.ts @@ -1,4 +1,6 @@ import { api } from 'src/api/main/index'; +import { AuthStoreType } from 'stores/auth-store'; +import Timeout = NodeJS.Timeout; export interface User { id: string; @@ -13,16 +15,38 @@ export interface TokenCredentials { } class AuthModule { - public async getToken(credentials: TokenCredentials): Promise { + private static readonly TOKEN_REFRESH_INTERVAL_MS = 30000; + + private tokenRefreshTimer?: Timeout; + + public async login(authStore: AuthStoreType, credentials: TokenCredentials) { + authStore.token = await this.fetchNewToken(credentials); + authStore.user = await this.fetchMyUser(authStore); + + clearTimeout(this.tokenRefreshTimer); + this.tokenRefreshTimer = setTimeout( + () => this.refreshToken(authStore), + AuthModule.TOKEN_REFRESH_INTERVAL_MS + ); + } + + public logout(authStore: AuthStoreType) { + authStore.$reset(); + clearTimeout(this.tokenRefreshTimer); + } + + private async fetchNewToken(credentials: TokenCredentials): Promise { const response = await api.post('/auth/token', credentials); return response.data.token; } - public async getMyUser(token: string): Promise { - const response = await api.get('/auth/me', { - headers: { - 'Authorization': 'Bearer ' + token - } - }); + + private async refreshToken(authStore: AuthStoreType) { + const response = await api.get('/auth/token', authStore.axiosConfig); + authStore.token = response.data.token; + } + + private async fetchMyUser(authStore: AuthStoreType): Promise { + const response = await api.get('/auth/me', authStore.axiosConfig); return response.data; } } diff --git a/gymboard-app/src/api/main/gyms.ts b/gymboard-app/src/api/main/gyms.ts index 6171b30..62c3d6a 100644 --- a/gymboard-app/src/api/main/gyms.ts +++ b/gymboard-app/src/api/main/gyms.ts @@ -1,7 +1,7 @@ import { GeoPoint } from 'src/api/main/models'; -import SubmissionsModule, {ExerciseSubmission} from 'src/api/main/submission'; +import SubmissionsModule, { ExerciseSubmission } from 'src/api/main/submission'; import { api } from 'src/api/main/index'; -import {GymRoutable} from 'src/router/gym-routing'; +import { GymRoutable } from 'src/router/gym-routing'; export interface Gym { countryCode: string; @@ -49,7 +49,9 @@ class GymsModule { }; } - public async getRecentSubmissions(gym: GymRoutable): Promise> { + public async getRecentSubmissions( + gym: GymRoutable + ): Promise> { const response = await api.get( `/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions` ); diff --git a/gymboard-app/src/api/main/leaderboards.ts b/gymboard-app/src/api/main/leaderboards.ts index e801d3c..b876bd3 100644 --- a/gymboard-app/src/api/main/leaderboards.ts +++ b/gymboard-app/src/api/main/leaderboards.ts @@ -3,49 +3,51 @@ import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing'; import { api } from 'src/api/main/index'; export enum LeaderboardTimeframe { - DAY = "DAY", - WEEK = "WEEK", - MONTH = "MONTH", - YEAR = "YEAR", - ALL = "ALL" + DAY = 'DAY', + WEEK = 'WEEK', + MONTH = 'MONTH', + YEAR = 'YEAR', + ALL = 'ALL', } export interface LeaderboardParams { - exerciseShortName?: string; - gyms?: Array; - timeframe?: LeaderboardTimeframe; - page?: number; - size?: number; + exerciseShortName?: string; + gyms?: Array; + timeframe?: LeaderboardTimeframe; + page?: number; + size?: number; } interface RequestParams { - exercise?: string; - gyms?: string; - t?: string; - page?: number; - size?: number; + exercise?: string; + gyms?: string; + t?: string; + page?: number; + size?: number; } class LeaderboardsModule { - public async getLeaderboard(params: LeaderboardParams): Promise> { - const requestParams: RequestParams = {}; - if (params.exerciseShortName) { - requestParams.exercise = params.exerciseShortName; - } - if (params.gyms) { - requestParams.gyms = params.gyms - .map(gym => getGymCompoundId(gym)) - .join(','); - } - if (params.timeframe) { - requestParams.t = params.timeframe; - } - if (params.page) requestParams.page = params.page; - if (params.size) requestParams.size = params.size; - - const response = await api.get('/leaderboards', { params: requestParams }); - return response.data.content; + public async getLeaderboard( + params: LeaderboardParams + ): Promise> { + const requestParams: RequestParams = {}; + if (params.exerciseShortName) { + requestParams.exercise = params.exerciseShortName; } + if (params.gyms) { + requestParams.gyms = params.gyms + .map((gym) => getGymCompoundId(gym)) + .join(','); + } + if (params.timeframe) { + requestParams.t = params.timeframe; + } + if (params.page) requestParams.page = params.page; + if (params.size) requestParams.size = params.size; + + const response = await api.get('/leaderboards', { params: requestParams }); + return response.data.content; + } } export default LeaderboardsModule; diff --git a/gymboard-app/src/api/main/submission.ts b/gymboard-app/src/api/main/submission.ts index df6364e..dc2bebc 100644 --- a/gymboard-app/src/api/main/submission.ts +++ b/gymboard-app/src/api/main/submission.ts @@ -1,6 +1,6 @@ import { SimpleGym } from 'src/api/main/gyms'; import { Exercise } from 'src/api/main/exercises'; -import {api, BASE_URL} from 'src/api/main/index'; +import { api, BASE_URL } from 'src/api/main/index'; import { getGymCompoundId, GymRoutable } from 'src/router/gym-routing'; import { sleep } from 'src/utils'; @@ -38,10 +38,10 @@ export enum ExerciseSubmissionStatus { } class SubmissionsModule { - public async getSubmission(submissionId: string): Promise { - const response = await api.get( - `/submissions/${submissionId}` - ); + public async getSubmission( + submissionId: string + ): Promise { + const response = await api.get(`/submissions/${submissionId}`); return response.data; } @@ -52,7 +52,7 @@ class SubmissionsModule { ) { return null; } - return BASE_URL + `/submissions/${submission.id}/video` + return BASE_URL + `/submissions/${submission.id}/video`; } public async createSubmission( @@ -60,10 +60,7 @@ class SubmissionsModule { payload: ExerciseSubmissionPayload ): Promise { const gymId = getGymCompoundId(gym); - const response = await api.post( - `/gyms/${gymId}/submissions`, - payload - ); + const response = await api.post(`/gyms/${gymId}/submissions`, payload); return response.data; } @@ -85,7 +82,9 @@ class SubmissionsModule { * Asynchronous method that waits until a submission is done processing. * @param submissionId The submission's id. */ - public async waitUntilSubmissionProcessed(submissionId: string): Promise { + public async waitUntilSubmissionProcessed( + submissionId: string + ): Promise { let failureCount = 0; let attemptCount = 0; while (failureCount < 5 && attemptCount < 60) { diff --git a/gymboard-app/src/components/AccountMenuItem.vue b/gymboard-app/src/components/AccountMenuItem.vue new file mode 100644 index 0000000..48321bd --- /dev/null +++ b/gymboard-app/src/components/AccountMenuItem.vue @@ -0,0 +1,36 @@ + + + + + diff --git a/gymboard-app/src/components/ExerciseSubmissionListItem.vue b/gymboard-app/src/components/ExerciseSubmissionListItem.vue index bdf8a26..057d092 100644 --- a/gymboard-app/src/components/ExerciseSubmissionListItem.vue +++ b/gymboard-app/src/components/ExerciseSubmissionListItem.vue @@ -1,7 +1,15 @@ diff --git a/gymboard-app/src/components/LocaleSelect.vue b/gymboard-app/src/components/LocaleSelect.vue new file mode 100644 index 0000000..956cd59 --- /dev/null +++ b/gymboard-app/src/components/LocaleSelect.vue @@ -0,0 +1,31 @@ + + + + + diff --git a/gymboard-app/src/i18n/en-US/index.ts b/gymboard-app/src/i18n/en-US/index.ts index fec432c..4c5961f 100644 --- a/gymboard-app/src/i18n/en-US/index.ts +++ b/gymboard-app/src/i18n/en-US/index.ts @@ -12,7 +12,7 @@ export default { leaderboard: 'Leaderboard', homePage: { overview: 'Overview of this gym:', - recentLifts: 'Recent Lifts' + recentLifts: 'Recent Lifts', }, submitPage: { name: 'Your Name', diff --git a/gymboard-app/src/i18n/nl-NL/index.ts b/gymboard-app/src/i18n/nl-NL/index.ts index 97848c8..cfccf3e 100644 --- a/gymboard-app/src/i18n/nl-NL/index.ts +++ b/gymboard-app/src/i18n/nl-NL/index.ts @@ -12,7 +12,7 @@ export default { leaderboard: 'Scorebord', homePage: { overview: 'Overzicht van dit sportschool:', - recentLifts: 'Recente liften' + recentLifts: 'Recente liften', }, submitPage: { name: 'Jouw naam', diff --git a/gymboard-app/src/layouts/MainLayout.vue b/gymboard-app/src/layouts/MainLayout.vue index d842419..d29ab5e 100644 --- a/gymboard-app/src/layouts/MainLayout.vue +++ b/gymboard-app/src/layouts/MainLayout.vue @@ -16,23 +16,8 @@ >Gymboard - + + @@ -55,13 +40,8 @@ - + diff --git a/gymboard-app/src/pages/gym/GymHomePage.vue b/gymboard-app/src/pages/gym/GymHomePage.vue index fe2896a..d681a1f 100644 --- a/gymboard-app/src/pages/gym/GymHomePage.vue +++ b/gymboard-app/src/pages/gym/GymHomePage.vue @@ -4,11 +4,22 @@

{{ $t('gymPage.homePage.overview') }}

    -
  • Website: {{ gym.websiteUrl }}
  • -
  • Address: {{ gym.streetAddress }}
  • -
  • City: {{ gym.cityName }}
  • -
  • Country: {{ gym.countryName }}
  • -
  • Registered at: {{ gym.createdAt }}
  • +
  • + Website: + {{ gym.websiteUrl }} +
  • +
  • + Address: {{ gym.streetAddress }} +
  • +
  • + City: {{ gym.cityName }} +
  • +
  • + Country: {{ gym.countryName }} +
  • +
  • + Registered at: {{ gym.createdAt }} +
@@ -19,27 +30,32 @@

{{ $t('gymPage.homePage.recentLifts') }}

- +
diff --git a/gymboard-app/src/router/gym-routing.ts b/gymboard-app/src/router/gym-routing.ts index cad3c97..0f36475 100644 --- a/gymboard-app/src/router/gym-routing.ts +++ b/gymboard-app/src/router/gym-routing.ts @@ -38,4 +38,4 @@ export async function getGymFromRoute(): Promise { */ export function getGymCompoundId(gym: GymRoutable): string { return `${gym.countryCode}_${gym.cityShortName}_${gym.shortName}`; -} \ No newline at end of file +} diff --git a/gymboard-app/src/stores/auth-store.ts b/gymboard-app/src/stores/auth-store.ts new file mode 100644 index 0000000..3337b8e --- /dev/null +++ b/gymboard-app/src/stores/auth-store.ts @@ -0,0 +1,34 @@ +/** + * This store keeps track of the authentication state of the web app, which + * is just keeping the current user and their token. + * + * See src/api/main/auth.ts for mutators of this store. + */ + +import { defineStore } from 'pinia'; +import { User } from 'src/api/main/auth'; + +interface AuthState { + user: User | null; + token: string | null; +} + +export const useAuthStore = defineStore('authStore', { + state: (): AuthState => { + return { user: null, token: null }; + }, + getters: { + loggedIn: (state) => state.user !== null && state.token !== null, + axiosConfig(state) { + if (this.token !== null) { + return { + headers: { Authorization: 'Bearer ' + state.token }, + }; + } else { + return {}; + } + }, + }, +}); + +export type AuthStoreType = ReturnType;