From 0e96d74203ae0079a8dad4e24e185b6ffc165f76 Mon Sep 17 00:00:00 2001 From: Andrew Lalis Date: Mon, 30 Jan 2023 10:07:28 +0100 Subject: [PATCH] Added users! --- gymboard-api/.gitignore | 3 + gymboard-api/gen_keys.d | 49 ++++++++ gymboard-api/pom.xml | 27 +++++ gymboard-api/sample_data/users.csv | 4 + .../config/SecurityComponents.java | 14 +++ .../gymboard_api/config/SecurityConfig.java | 38 +++++++ .../config/TokenAuthenticationFilter.java | 38 +++++++ .../controller/AuthController.java | 22 ++++ .../controller/dto/TokenCredentials.java | 6 + .../controller/dto/TokenResponse.java | 3 + .../controller/dto/UserCreationPayload.java | 7 ++ .../controller/dto/UserResponse.java | 19 ++++ .../gymboard_api/dao/auth/RoleRepository.java | 9 ++ .../gymboard_api/dao/auth/UserRepository.java | 21 ++++ .../gymboard_api/model/SampleDataLoader.java | 36 +++++- .../gymboard_api/model/auth/Role.java | 24 ++++ .../model/auth/TokenAuthentication.java | 47 ++++++++ .../gymboard_api/model/auth/User.java | 78 +++++++++++++ .../service/auth/TokenService.java | 105 ++++++++++++++++++ .../service/auth/UserService.java | 45 ++++++++ .../application-development.properties | 3 + 21 files changed, 596 insertions(+), 2 deletions(-) create mode 100755 gymboard-api/gen_keys.d create mode 100644 gymboard-api/sample_data/users.csv create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java create mode 100644 gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java diff --git a/gymboard-api/.gitignore b/gymboard-api/.gitignore index 02a81a7..71753f8 100644 --- a/gymboard-api/.gitignore +++ b/gymboard-api/.gitignore @@ -33,3 +33,6 @@ build/ .sample_data exercise_submission_temp_files/ +private_key.der +private_key.pem +public_key.der diff --git a/gymboard-api/gen_keys.d b/gymboard-api/gen_keys.d new file mode 100755 index 0000000..81376d6 --- /dev/null +++ b/gymboard-api/gen_keys.d @@ -0,0 +1,49 @@ +#!/usr/bin/env rdmd +/** + * A simple script that generates the keys needed by DigiOPP Auth for signing + * tokens and other cryptographic needs. Use this in your development + * environment to ensure that your local auth project can issue keys to other + * development services. + * + * Authors: Andrew Lalis + */ +module gen_keys; + +import std.stdio; +import std.file; + +const privateKeyFile = "private_key.pem"; +const privateKeyDerFile = "private_key.der"; +const publicKeyDerFile = "public_key.der"; + +const cmdGenRSAPrivateKey = "openssl genrsa -out private_key.pem 2048"; +const cmdGenDERPrivateKey = "openssl pkcs8 -topk8 -inform PEM -outform DER -in private_key.pem -out private_key.der -nocrypt"; +const cmdGenDERPublicKey = "openssl rsa -in private_key.pem -pubout -outform DER -out public_key.der"; + +void removeIfExists(string[] files...) { + foreach (f; files) if (exists(f)) remove(f); +} + +void genKeys(string[] files...) { + import std.process : executeShell; + import std.algorithm : canFind; + if (canFind(files, privateKeyFile)) executeShell(cmdGenRSAPrivateKey); + if (canFind(files, privateKeyDerFile)) executeShell(cmdGenDERPrivateKey); + if (canFind(files, publicKeyDerFile)) executeShell(cmdGenDERPublicKey); +} + +void main() { + if (!exists(privateKeyFile)) { + writeln("No RSA private key found. Regenerating all key files."); + removeIfExists(privateKeyDerFile, publicKeyDerFile); + genKeys(privateKeyFile, privateKeyDerFile, publicKeyDerFile); + } else if (!exists(privateKeyDerFile)) { + writeln("No DER private key found. Regenerating private and public DER files."); + removeIfExists(privateKeyDerFile, publicKeyDerFile); + genKeys(privateKeyDerFile, publicKeyDerFile); + } else if (!exists(publicKeyDerFile)) { + writeln("No DER public key found. Regenerating it."); + genKeys(publicKeyDerFile); + } + writeln("All keys are now generated."); +} diff --git a/gymboard-api/pom.xml b/gymboard-api/pom.xml index c1e6970..60f0eb6 100644 --- a/gymboard-api/pom.xml +++ b/gymboard-api/pom.xml @@ -21,6 +21,10 @@ org.springframework.boot spring-boot-starter-data-jpa + + org.springframework.boot + spring-boot-starter-security + org.springframework.boot spring-boot-starter-validation @@ -29,6 +33,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-websocket + org.postgresql @@ -41,6 +49,25 @@ 1.9.0 + + + io.jsonwebtoken + jjwt-api + 0.11.2 + + + io.jsonwebtoken + jjwt-impl + 0.11.2 + runtime + + + io.jsonwebtoken + jjwt-jackson + 0.11.2 + runtime + + org.springframework.boot diff --git a/gymboard-api/sample_data/users.csv b/gymboard-api/sample_data/users.csv new file mode 100644 index 0000000..473acfb --- /dev/null +++ b/gymboard-api/sample_data/users.csv @@ -0,0 +1,4 @@ +jay.cutler@example.com,testpass,Jay Cutler, +mike.mentzer@example.com,testpass,Mike Mentzer, +ronnie.coleman@example.com,testpass,Ronnie 'Lightweight' Coleman, +andrew.lalis@example.com,testpass,Andrew Lalis,admin \ No newline at end of file diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java new file mode 100644 index 0000000..5af3e06 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityComponents.java @@ -0,0 +1,14 @@ +package nl.andrewlalis.gymboard_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@Configuration +public class SecurityComponents { + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(10); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java new file mode 100644 index 0000000..59cf65f --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/SecurityConfig.java @@ -0,0 +1,38 @@ +package nl.andrewlalis.gymboard_api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + private final TokenAuthenticationFilter tokenAuthenticationFilter; + + public SecurityConfig(TokenAuthenticationFilter tokenAuthenticationFilter) { + this.tokenAuthenticationFilter = tokenAuthenticationFilter; + } + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + .httpBasic().disable() + .sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS).and() + .csrf().disable() + .addFilterBefore(tokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) + .authorizeHttpRequests().anyRequest().permitAll(); + return http.build(); + } + + @Bean + public AuthenticationManager authenticationManager() { + return null;// Disable the standard spring authentication manager. + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java new file mode 100644 index 0000000..caa1615 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/config/TokenAuthenticationFilter.java @@ -0,0 +1,38 @@ +package nl.andrewlalis.gymboard_api.config; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; +import nl.andrewlalis.gymboard_api.model.auth.TokenAuthentication; +import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class TokenAuthenticationFilter extends OncePerRequestFilter { + private final TokenService tokenService; + private final UserRepository userRepository; + + public TokenAuthenticationFilter(TokenService tokenService, UserRepository userRepository) { + this.tokenService = tokenService; + this.userRepository = userRepository; + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String token = tokenService.extractBearerToken(request); + Jws jws = tokenService.getToken(token); + if (jws != null) { + userRepository.findByIdWithRoles(jws.getBody().getSubject()) + .ifPresent(user -> SecurityContextHolder.getContext().setAuthentication(new TokenAuthentication(user, token))); + } + filterChain.doFilter(request, response); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java new file mode 100644 index 0000000..52901e0 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/AuthController.java @@ -0,0 +1,22 @@ +package nl.andrewlalis.gymboard_api.controller; + +import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; +import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; +import nl.andrewlalis.gymboard_api.service.auth.TokenService; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +public class AuthController { + private final TokenService tokenService; + + public AuthController(TokenService tokenService) { + this.tokenService = tokenService; + } + + @PostMapping(path = "/auth/token") + public TokenResponse getToken(@RequestBody TokenCredentials credentials) { + return tokenService.generateAccessToken(credentials); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java new file mode 100644 index 0000000..5637abb --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenCredentials.java @@ -0,0 +1,6 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record TokenCredentials( + String email, + String password +) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java new file mode 100644 index 0000000..99aef46 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/TokenResponse.java @@ -0,0 +1,3 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record TokenResponse(String token) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java new file mode 100644 index 0000000..4e8446f --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserCreationPayload.java @@ -0,0 +1,7 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +public record UserCreationPayload( + String email, + String password, + String name +) {} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java new file mode 100644 index 0000000..592ddd9 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/controller/dto/UserResponse.java @@ -0,0 +1,19 @@ +package nl.andrewlalis.gymboard_api.controller.dto; + +import nl.andrewlalis.gymboard_api.model.auth.User; + +public record UserResponse( + String id, + boolean activated, + String email, + String name +) { + public UserResponse(User user) { + this( + user.getId(), + user.isActivated(), + user.getEmail(), + user.getName() + ); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java new file mode 100644 index 0000000..50a2b5d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/RoleRepository.java @@ -0,0 +1,9 @@ +package nl.andrewlalis.gymboard_api.dao.auth; + +import nl.andrewlalis.gymboard_api.model.auth.Role; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface RoleRepository extends JpaRepository { +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java new file mode 100644 index 0000000..31e7d58 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/dao/auth/UserRepository.java @@ -0,0 +1,21 @@ +package nl.andrewlalis.gymboard_api.dao.auth; + +import nl.andrewlalis.gymboard_api.model.auth.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository, JpaSpecificationExecutor { + boolean existsByEmail(String email); + Optional findByEmail(String email); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.id = :id") + Optional findByIdWithRoles(String id); + + @Query("SELECT u FROM User u LEFT JOIN FETCH u.roles WHERE u.email = :email") + Optional findByEmailWithRoles(String email); +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java index 6b56140..907ef7f 100644 --- a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/SampleDataLoader.java @@ -2,14 +2,20 @@ package nl.andrewlalis.gymboard_api.model; import nl.andrewlalis.gymboard_api.controller.dto.CompoundGymId; import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload; +import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; import nl.andrewlalis.gymboard_api.dao.CityRepository; import nl.andrewlalis.gymboard_api.dao.CountryRepository; import nl.andrewlalis.gymboard_api.dao.GymRepository; +import nl.andrewlalis.gymboard_api.dao.auth.RoleRepository; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; import nl.andrewlalis.gymboard_api.dao.exercise.ExerciseRepository; +import nl.andrewlalis.gymboard_api.model.auth.Role; +import nl.andrewlalis.gymboard_api.model.auth.User; import nl.andrewlalis.gymboard_api.model.exercise.Exercise; import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission; -import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import nl.andrewlalis.gymboard_api.service.UploadService; +import nl.andrewlalis.gymboard_api.service.auth.UserService; +import nl.andrewlalis.gymboard_api.service.submission.ExerciseSubmissionService; import org.apache.commons.csv.CSVFormat; import org.apache.commons.csv.CSVRecord; import org.slf4j.Logger; @@ -39,19 +45,27 @@ public class SampleDataLoader implements ApplicationListener { + String email = record.get(0); + String password = record.get(1); + String name = record.get(2); + String[] roleNames = record.get(3).split("\\s*\\|\\s*"); + + UserCreationPayload payload = new UserCreationPayload(email, password, name); + var resp = userService.createUser(payload); + User user = userRepository.findByIdWithRoles(resp.id()).orElseThrow(); + for (var roleName : roleNames) { + if (roleName.isBlank()) continue; + Role role = roleRepository.findById(roleName.strip().toLowerCase()) + .orElseGet(() -> roleRepository.save(new Role(roleName.strip().toLowerCase()))); + user.getRoles().add(role); + } + userRepository.save(user); + }); } private void loadCsv(String csvName, Consumer recordConsumer) throws IOException { diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java new file mode 100644 index 0000000..4c9dea8 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/Role.java @@ -0,0 +1,24 @@ +package nl.andrewlalis.gymboard_api.model.auth; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "auth_role") +public class Role { + @Id + @Column(nullable = false) + private String shortName; + + public Role() {} + + public Role(String shortName) { + this.shortName = shortName; + } + + public String getShortName() { + return shortName; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java new file mode 100644 index 0000000..8860369 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/TokenAuthentication.java @@ -0,0 +1,47 @@ +package nl.andrewlalis.gymboard_api.model.auth; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; + +import java.util.Collection; +import java.util.Collections; + +public record TokenAuthentication( + User user, + String token +) implements Authentication { + @Override + public Collection getAuthorities() { + return Collections.emptyList(); + } + + @Override + public Object getCredentials() { + return token; + } + + @Override + public Object getDetails() { + return null; + } + + @Override + public Object getPrincipal() { + return user; + } + + @Override + public boolean isAuthenticated() { + return true; + } + + @Override + public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { + // Not allowed. + } + + @Override + public String getName() { + return user.getEmail(); + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java new file mode 100644 index 0000000..13c948d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/model/auth/User.java @@ -0,0 +1,78 @@ +package nl.andrewlalis.gymboard_api.model.auth; + +import jakarta.persistence.*; +import org.hibernate.annotations.CreationTimestamp; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.Set; + +@Entity +@Table(name = "auth_user") +public class User { + @Id + @Column(nullable = false, updatable = false, length = 26) + private String id; + + @CreationTimestamp + private LocalDateTime createdAt; + + @Column(nullable = false) + private boolean activated; + + @Column(nullable = false, unique = true) + private String email; + + @Column(nullable = false) + private String passwordHash; + + @Column(nullable = false) + private String name; + + @ManyToMany(fetch = FetchType.LAZY) + @JoinTable( + name = "auth_user_roles", + joinColumns = @JoinColumn(name = "user_id"), + inverseJoinColumns = @JoinColumn(name = "role_short_name") + ) + private Set roles; + + public User() {} + + public User(String id, boolean activated, String email, String passwordHash, String name) { + this.id = id; + this.activated = activated; + this.email = email; + this.passwordHash = passwordHash; + this.name = name; + this.roles = new HashSet<>(); + } + + public String getId() { + return id; + } + + public LocalDateTime getCreatedAt() { + return createdAt; + } + + public boolean isActivated() { + return activated; + } + + public String getEmail() { + return email; + } + + public String getPasswordHash() { + return passwordHash; + } + + public String getName() { + return name; + } + + public Set getRoles() { + return roles; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java new file mode 100644 index 0000000..038bc7d --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/TokenService.java @@ -0,0 +1,105 @@ +package nl.andrewlalis.gymboard_api.service.auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import jakarta.servlet.http.HttpServletRequest; +import nl.andrewlalis.gymboard_api.controller.dto.TokenCredentials; +import nl.andrewlalis.gymboard_api.controller.dto.TokenResponse; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; +import nl.andrewlalis.gymboard_api.model.auth.Role; +import nl.andrewlalis.gymboard_api.model.auth.User; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.web.server.ResponseStatusException; + +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.KeyFactory; +import java.security.PrivateKey; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.Date; +import java.util.stream.Collectors; + +@Service +public class TokenService { + private static final Logger log = LoggerFactory.getLogger(TokenService.class); + private static final String BEARER_PREFIX = "Bearer "; + private static final String ISSUER = "Gymboard"; + private PrivateKey privateKey = null; + + @Value("${app.auth.private-key-location}") + private String privateKeyLocation; + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public TokenService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public String generateAccessToken(User user) { + Instant expiration = Instant.now().plus(30, ChronoUnit.MINUTES); + return Jwts.builder() + .setSubject(user.getId()) + .setIssuer(ISSUER) + .setAudience("Gymboard App") + .setExpiration(Date.from(expiration)) + .claim("email", user.getEmail()) + .claim("name", user.getName()) + .claim("roles", user.getRoles().stream() + .map(Role::getShortName) + .collect(Collectors.joining(","))) + .signWith(getPrivateKey()) + .compact(); + } + + public TokenResponse generateAccessToken(TokenCredentials credentials) { + User user = userRepository.findByEmailWithRoles(credentials.email()) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.UNAUTHORIZED)); + if (!passwordEncoder.matches(credentials.password(), user.getPasswordHash())) { + throw new ResponseStatusException(HttpStatus.UNAUTHORIZED); + } + String token = generateAccessToken(user); + return new TokenResponse(token); + } + + public String extractBearerToken(HttpServletRequest request) { + String authorizationHeader = request.getHeader("Authorization"); + if (authorizationHeader == null || authorizationHeader.isBlank()) return null; + if (authorizationHeader.startsWith(BEARER_PREFIX)) { + return authorizationHeader.substring(BEARER_PREFIX.length()); + } + return null; + } + + public Jws getToken(String token) { + if (token == null) return null; + var builder = Jwts.parserBuilder() + .setSigningKey(this.getPrivateKey()) + .requireIssuer(ISSUER); + return builder.build().parseClaimsJws(token); + } + + private PrivateKey getPrivateKey() { + if (privateKey == null) { + try { + byte[] keyBytes = Files.readAllBytes(Path.of(this.privateKeyLocation)); + PKCS8EncodedKeySpec spec = new PKCS8EncodedKeySpec(keyBytes); + KeyFactory kf = KeyFactory.getInstance("RSA"); + privateKey = kf.generatePrivate(spec); + } catch (Exception e) { + log.error("Could not obtain private key.", e); + throw new RuntimeException("Cannot obtain private key.", e); + } + } + return privateKey; + } +} diff --git a/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java new file mode 100644 index 0000000..32df224 --- /dev/null +++ b/gymboard-api/src/main/java/nl/andrewlalis/gymboard_api/service/auth/UserService.java @@ -0,0 +1,45 @@ +package nl.andrewlalis.gymboard_api.service.auth; + +import nl.andrewlalis.gymboard_api.controller.dto.UserCreationPayload; +import nl.andrewlalis.gymboard_api.controller.dto.UserResponse; +import nl.andrewlalis.gymboard_api.dao.auth.UserRepository; +import nl.andrewlalis.gymboard_api.model.auth.User; +import nl.andrewlalis.gymboard_api.util.ULID; +import org.springframework.http.HttpStatus; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.server.ResponseStatusException; + +@Service +public class UserService { + private final UserRepository userRepository; + private final ULID ulid; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, ULID ulid, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.ulid = ulid; + this.passwordEncoder = passwordEncoder; + } + + @Transactional(readOnly = true) + public UserResponse getUser(String id) { + User user = userRepository.findById(id) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND)); + return new UserResponse(user); + } + + @Transactional + public UserResponse createUser(UserCreationPayload payload) { + // TODO: Validate user payload. + User user = userRepository.save(new User( + ulid.nextULID(), + true, // TODO: Change this to false once email activation is in. + payload.email(), + passwordEncoder.encode(payload.password()), + payload.name() + )); + return new UserResponse(user); + } +} diff --git a/gymboard-api/src/main/resources/application-development.properties b/gymboard-api/src/main/resources/application-development.properties index 13bdea4..974c949 100644 --- a/gymboard-api/src/main/resources/application-development.properties +++ b/gymboard-api/src/main/resources/application-development.properties @@ -8,3 +8,6 @@ spring.jpa.show-sql=false spring.task.execution.pool.core-size=3 spring.task.execution.pool.max-size=10 + +app.auth.private-key-location=./private_key.der +