Merge remote-tracking branch 'origin/main' into main

# Conflicts:
#	gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/WebConfig.java
#	gymboard-api/src/main/resources/application-development.properties
#	gymboard-app/src/layouts/MainLayout.vue
This commit is contained in:
Andrew Lalis 2023-01-30 21:54:08 +01:00
commit 595a33a801
43 changed files with 1012 additions and 122 deletions

View File

@ -33,3 +33,6 @@ build/
.sample_data
exercise_submission_temp_files/
private_key.der
private_key.pem
public_key.der

49
gymboard-api/gen_keys.d Executable file
View File

@ -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.");
}

View File

@ -21,6 +21,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
@ -29,6 +33,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
@ -41,6 +49,25 @@
<version>1.9.0</version>
</dependency>
<!-- JWT dependencies -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.11.2</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId> <!-- or jjwt-gson if Gson is preferred -->
<version>0.11.2</version>
<scope>runtime</scope>
</dependency>
<!-- Test dependencies -->
<dependency>
<groupId>org.springframework.boot</groupId>

View File

@ -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
1 jay.cutler@example.com testpass Jay Cutler
2 mike.mentzer@example.com testpass Mike Mentzer
3 ronnie.coleman@example.com testpass Ronnie 'Lightweight' Coleman
4 andrew.lalis@example.com testpass Andrew Lalis admin

View File

@ -0,0 +1,85 @@
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;
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;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
private final TokenAuthenticationFilter tokenAuthenticationFilter;
public SecurityConfig(TokenAuthenticationFilter tokenAuthenticationFilter) {
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()
.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();
}
/**
* 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.
}
}

View File

@ -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<Claims> 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);
}
}

View File

@ -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 WebComponents {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(10);
}
}

View File

@ -1,15 +1,8 @@
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 {

View File

@ -0,0 +1,58 @@
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.core.Authentication;
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;
@RestController
public class AuthController {
private final TokenService tokenService;
public AuthController(TokenService tokenService) {
this.tokenService = tokenService;
}
/**
* Endpoint for obtaining a new access token for a user to access certain
* parts of the application. <strong>This is a public endpoint.</strong> 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")
public UserResponse getMyUser(@AuthenticationPrincipal User user) {
return new UserResponse(user);
}
}

View File

@ -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;

View File

@ -0,0 +1,6 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record TokenCredentials(
String email,
String password
) {}

View File

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

View File

@ -0,0 +1,7 @@
package nl.andrewlalis.gymboard_api.controller.dto;
public record UserCreationPayload(
String email,
String password,
String name
) {}

View File

@ -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()
);
}
}

View File

@ -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<Role, String> {
}

View File

@ -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<User, String>, JpaSpecificationExecutor<User> {
boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id")
Optional<User> findByIdWithRoles(String id);
@Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email")
Optional<User> findByEmailWithRoles(String email);
}

View File

@ -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<ContextRefreshedEve
private final ExerciseRepository exerciseRepository;
private final ExerciseSubmissionService submissionService;
private final UploadService uploadService;
private final RoleRepository roleRepository;
private final UserRepository userRepository;
private final UserService userService;
public SampleDataLoader(
CountryRepository countryRepository,
CityRepository cityRepository,
GymRepository gymRepository,
ExerciseRepository exerciseRepository,
ExerciseSubmissionService submissionService, UploadService uploadService) {
ExerciseSubmissionService submissionService,
UploadService uploadService,
RoleRepository roleRepository, UserRepository userRepository, UserService userService) {
this.countryRepository = countryRepository;
this.cityRepository = cityRepository;
this.gymRepository = gymRepository;
this.exerciseRepository = exerciseRepository;
this.submissionService = submissionService;
this.uploadService = uploadService;
this.roleRepository = roleRepository;
this.userRepository = userRepository;
this.userService = userService;
}
@Override
@ -122,6 +136,24 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
e.printStackTrace();
}
});
loadCsv("users", record -> {
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<CSVRecord> recordConsumer) throws IOException {

View File

@ -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;
}
}

View File

@ -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();
}
}

View File

@ -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<Role> 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<Role> getRoles() {
return roles;
}
}

View File

@ -0,0 +1,123 @@
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.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;
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);
}
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);
}
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<Claims> 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;
}
}

View File

@ -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);
}
}

View File

@ -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<ExerciseSubmissionTempFile> 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;
}

View File

@ -9,4 +9,5 @@ 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
app.web-origin=http://localhost:9000

View File

@ -0,0 +1,54 @@
import { api } from 'src/api/main/index';
import { AuthStoreType } from 'stores/auth-store';
import Timeout = NodeJS.Timeout;
export interface User {
id: string;
activated: boolean;
email: string;
name: string;
}
export interface TokenCredentials {
email: string;
password: string;
}
class AuthModule {
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<string> {
const response = await api.post('/auth/token', credentials);
return response.data.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<User> {
const response = await api.get('/auth/me', authStore.axiosConfig);
return response.data;
}
}
export default AuthModule;

View File

@ -49,7 +49,9 @@ class GymsModule {
};
}
public async getRecentSubmissions(gym: GymRoutable): Promise<Array<ExerciseSubmission>> {
public async getRecentSubmissions(
gym: GymRoutable
): Promise<Array<ExerciseSubmission>> {
const response = await api.get(
`/gyms/${gym.countryCode}_${gym.cityShortName}_${gym.shortName}/recent-submissions`
);

View File

@ -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();

View File

@ -3,11 +3,11 @@ 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 {
@ -27,14 +27,16 @@ interface RequestParams {
}
class LeaderboardsModule {
public async getLeaderboard(params: LeaderboardParams): Promise<Array<ExerciseSubmission>> {
public async getLeaderboard(
params: LeaderboardParams
): Promise<Array<ExerciseSubmission>> {
const requestParams: RequestParams = {};
if (params.exerciseShortName) {
requestParams.exercise = params.exerciseShortName;
}
if (params.gyms) {
requestParams.gyms = params.gyms
.map(gym => getGymCompoundId(gym))
.map((gym) => getGymCompoundId(gym))
.join(',');
}
if (params.timeframe) {

View File

@ -38,10 +38,10 @@ export enum ExerciseSubmissionStatus {
}
class SubmissionsModule {
public async getSubmission(submissionId: string): Promise<ExerciseSubmission> {
const response = await api.get(
`/submissions/${submissionId}`
);
public async getSubmission(
submissionId: string
): Promise<ExerciseSubmission> {
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<ExerciseSubmission> {
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<ExerciseSubmission> {
public async waitUntilSubmissionProcessed(
submissionId: string
): Promise<ExerciseSubmission> {
let failureCount = 0;
let attemptCount = 0;
while (failureCount < 5 && attemptCount < 60) {

View File

@ -0,0 +1,36 @@
<template>
<div class="q-mx-sm">
<q-btn-dropdown
color="primary"
:label="authStore.user?.name"
v-if="authStore.loggedIn"
no-caps
icon="person"
>
<q-list>
<q-item clickable v-close-popup @click="api.auth.logout(authStore)">
<q-item-section>
<q-item-label>Log out</q-item-label>
</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
color="primary"
:label="$t('Login')"
v-if="!authStore.loggedIn"
no-caps
icon="person"
to="/login"
/>
</div>
</template>
<script setup lang="ts">
import { useAuthStore } from 'stores/auth-store';
import api from 'src/api/main';
const authStore = useAuthStore();
</script>
<style scoped></style>

View File

@ -1,7 +1,15 @@
<template>
<q-expansion-item
expand-separator
:label="submission.rawWeight + ' ' + submission.weightUnit + ' x' + submission.reps + ' ' + submission.exercise.displayName"
:label="
submission.rawWeight +
' ' +
submission.weightUnit +
' x' +
submission.reps +
' ' +
submission.exercise.displayName
"
:caption="submission.submitterName"
>
<q-card>
@ -24,7 +32,7 @@ import {ExerciseSubmission} from 'src/api/main/submission';
import api from 'src/api/main';
interface Props {
submission: ExerciseSubmission
submission: ExerciseSubmission;
}
defineProps<Props>();
</script>

View File

@ -0,0 +1,31 @@
<template>
<q-select
v-model="i18n.locale.value"
:options="localeOptions"
:label="$t('mainLayout.language')"
dense
borderless
emit-value
map-options
options-dense
filled
hide-bottom-space
dark
options-dark
label-color="white"
options-selected-class="text-grey"
style="min-width: 150px"
/>
</template>
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
const i18n = useI18n({ useScope: 'global' });
const localeOptions = [
{ value: 'en-US', label: 'English' },
{ value: 'nl-NL', label: 'Nederlands' },
];
</script>
<style scoped></style>

View File

@ -12,7 +12,7 @@ export default {
leaderboard: 'Leaderboard',
homePage: {
overview: 'Overview of this gym:',
recentLifts: 'Recent Lifts'
recentLifts: 'Recent Lifts',
},
submitPage: {
name: 'Your Name',

View File

@ -12,7 +12,7 @@ export default {
leaderboard: 'Scorebord',
homePage: {
overview: 'Overzicht van dit sportschool:',
recentLifts: 'Recente liften'
recentLifts: 'Recente liften',
},
submitPage: {
name: 'Jouw naam',

View File

@ -16,23 +16,8 @@
>Gymboard</router-link
>
</q-toolbar-title>
<q-select
v-model="i18n.locale.value"
:options="localeOptions"
:label="$t('mainLayout.language')"
dense
borderless
emit-value
map-options
options-dense
filled
hide-bottom-space
dark
options-dark
label-color="white"
options-selected-class="text-grey"
style="min-width: 150px"
/>
<AccountMenuItem />
<LocaleSelect />
</q-toolbar>
</q-header>
@ -43,6 +28,7 @@
</q-item-label>
<q-item clickable to="/">Gyms</q-item>
<q-item clickable>Global Leaderboard</q-item>
<q-item clickable to="/testing">Testing Page</q-item>
<q-item clickable to="/about">About</q-item>
</q-list>
</q-drawer>
@ -55,14 +41,8 @@
<script setup lang="ts">
import { ref } from 'vue';
import { useI18n } from 'vue-i18n';
const i18n = useI18n({ useScope: 'global' });
const localeOptions = [
{ value: 'en-US', label: 'English' },
{ value: 'nl-NL', label: 'Nederlands' },
{ value: 'de', label: 'Deutsch' },
];
import LocaleSelect from 'components/LocaleSelect.vue';
import AccountMenuItem from 'components/AccountMenuItem.vue';
const leftDrawerOpen = ref(false);

View File

@ -0,0 +1,31 @@
<template>
<StandardCenteredPage>
<h3>Testing Page</h3>
<p>
Use this page to test new functionality, before adding it to the main app.
This page should be hidden on production.
</p>
<div style="border: 3px solid red">
<h4>Auth Test</h4>
<q-btn label="Do auth" @click="doAuth()" />
<q-btn label="Logout" @click="api.auth.logout(authStore)" />
</div>
</StandardCenteredPage>
</template>
<script setup lang="ts">
import StandardCenteredPage from 'src/components/StandardCenteredPage.vue';
import api from 'src/api/main';
import { useAuthStore } from 'stores/auth-store';
const authStore = useAuthStore();
async function doAuth() {
await api.auth.login(authStore, {
email: 'andrew.lalis@example.com',
password: 'testpass',
});
}
</script>
<style scoped></style>

View File

@ -4,11 +4,22 @@
<div class="col-xs-12 col-md-6 q-pt-md">
<p>{{ $t('gymPage.homePage.overview') }}</p>
<ul>
<li v-if="gym.websiteUrl">Website: <a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a></li>
<li>Address: <em>{{ gym.streetAddress }}</em></li>
<li>City: <em>{{ gym.cityName }}</em></li>
<li>Country: <em>{{ gym.countryName }}</em></li>
<li>Registered at: <em>{{ gym.createdAt }}</em></li>
<li v-if="gym.websiteUrl">
Website:
<a :href="gym.websiteUrl" target="_blank">{{ gym.websiteUrl }}</a>
</li>
<li>
Address: <em>{{ gym.streetAddress }}</em>
</li>
<li>
City: <em>{{ gym.cityName }}</em>
</li>
<li>
Country: <em>{{ gym.countryName }}</em>
</li>
<li>
Registered at: <em>{{ gym.createdAt }}</em>
</li>
</ul>
</div>
<div class="col-xs-12 col-md-6">
@ -19,7 +30,11 @@
<div v-if="recentSubmissions.length > 0">
<h4 class="text-center">{{ $t('gymPage.homePage.recentLifts') }}</h4>
<q-list>
<ExerciseSubmissionListItem v-for="sub in recentSubmissions" :submission="sub" :key="sub.id"/>
<ExerciseSubmissionListItem
v-for="sub in recentSubmissions"
:submission="sub"
:key="sub.id"
/>
</q-list>
</div>
</q-page>
@ -39,7 +54,8 @@ const recentSubmissions: Ref<Array<ExerciseSubmission>> = ref([]);
const gym: Ref<Gym | undefined> = ref();
const TILE_URL = 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png';
const ATTRIBUTION = '&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>';
const ATTRIBUTION =
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a>';
const map: Ref<Map | undefined> = ref();
const mapContainer = ref();
@ -55,13 +71,18 @@ function initMap() {
const g: Gym = gym.value;
console.log(mapContainer);
const tiles = new TileLayer(TILE_URL, { attribution: ATTRIBUTION, maxZoom: 19 });
const tiles = new TileLayer(TILE_URL, {
attribution: ATTRIBUTION,
maxZoom: 19,
});
const marker = new Marker([g.location.latitude, g.location.longitude], {
title: g.displayName,
alt: g.displayName
alt: g.displayName,
});
map.value = new Map(mapContainer.value, {})
.setView([g.location.latitude, g.location.longitude], 16);
map.value = new Map(mapContainer.value, {}).setView(
[g.location.latitude, g.location.longitude],
16
);
tiles.addTo(map.value);
marker.addTo(map.value);

View File

@ -1,11 +1,7 @@
<template>
<q-page>
<div class="q-ma-md row justify-end q-gutter-sm">
<q-spinner
color="primary"
size="3em"
v-if="loadingIndicatorActive"
/>
<q-spinner color="primary" size="3em" v-if="loadingIndicatorActive" />
<q-select
v-model="selectedExercise"
:options="exerciseOptions"
@ -22,7 +18,11 @@
/>
</div>
<q-list>
<ExerciseSubmissionListItem v-for="sub in submissions" :submission="sub" :key="sub.id"/>
<ExerciseSubmissionListItem
v-for="sub in submissions"
:submission="sub"
:key="sub.id"
/>
</q-list>
</q-page>
</template>
@ -43,10 +43,10 @@ const gym: Ref<Gym | undefined> = ref();
const exercises: Ref<Array<Exercise>> = ref([]);
const exerciseOptions = computed(() => {
let options = exercises.value.map(exercise => {
let options = exercises.value.map((exercise) => {
return {
value: exercise.shortName,
label: exercise.displayName
label: exercise.displayName,
};
});
options.push({ value: '', label: 'Any' });
@ -61,7 +61,9 @@ const timeframeOptions = [
{ value: LeaderboardTimeframe.YEAR, label: 'Year' },
{ value: LeaderboardTimeframe.ALL, label: 'All' },
];
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(LeaderboardTimeframe.DAY);
const selectedTimeframe: Ref<LeaderboardTimeframe> = ref(
LeaderboardTimeframe.DAY
);
const loadingIndicatorActive = ref(false);
@ -79,7 +81,7 @@ async function doSearch() {
submissions.value = await api.leaderboards.getLeaderboard({
timeframe: selectedTimeframe.value,
gyms: [gym.value],
exerciseShortName: selectedExercise.value
exerciseShortName: selectedExercise.value,
});
loadingIndicatorActive.value = false;
}

View File

@ -144,7 +144,9 @@ onMounted(async () => {
});
function submitButtonEnabled() {
return selectedVideoFile.value !== undefined && !submitting.value && validateForm();
return (
selectedVideoFile.value !== undefined && !submitting.value && validateForm()
);
}
function validateForm() {
@ -176,7 +178,6 @@ async function onSubmitted() {
} finally {
submitting.value = false;
}
}
</script>

View File

@ -6,6 +6,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[] = [
{
@ -13,6 +14,7 @@ const routes: RouteRecordRaw[] = [
component: MainLayout,
children: [
{ path: '', component: IndexPage },
{ path: 'testing', component: TestingPage },
{
path: 'gyms/:countryCode/:cityShortName/:gymShortName',
component: GymPage,

View File

@ -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<typeof useAuthStore>;