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 extends GrantedAuthority> 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 @@
+
+
+ Testing Page
+
+ Use this page to test new functionality, before adding it to the main
+ app. This page should be hidden on production.
+
+
+
Auth Test
+
+
{{ authTestMessage }}
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ Log out
+
+
+
+
+
+
+
+
+
+
+
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 @@
@@ -20,11 +28,11 @@
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;