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
+