Added users!
This commit is contained in:
parent
1034f7e65a
commit
0e96d74203
|
@ -33,3 +33,6 @@ build/
|
||||||
|
|
||||||
.sample_data
|
.sample_data
|
||||||
exercise_submission_temp_files/
|
exercise_submission_temp_files/
|
||||||
|
private_key.der
|
||||||
|
private_key.pem
|
||||||
|
public_key.der
|
||||||
|
|
|
@ -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.");
|
||||||
|
}
|
|
@ -21,6 +21,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-security</artifactId>
|
||||||
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
|
@ -29,6 +33,10 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-web</artifactId>
|
<artifactId>spring-boot-starter-web</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.postgresql</groupId>
|
<groupId>org.postgresql</groupId>
|
||||||
|
@ -41,6 +49,25 @@
|
||||||
<version>1.9.0</version>
|
<version>1.9.0</version>
|
||||||
</dependency>
|
</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 -->
|
<!-- Test dependencies -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
|
|
@ -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
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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.
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,6 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||||
|
|
||||||
|
public record TokenCredentials(
|
||||||
|
String email,
|
||||||
|
String password
|
||||||
|
) {}
|
|
@ -0,0 +1,3 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||||
|
|
||||||
|
public record TokenResponse(String token) {}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package nl.andrewlalis.gymboard_api.controller.dto;
|
||||||
|
|
||||||
|
public record UserCreationPayload(
|
||||||
|
String email,
|
||||||
|
String password,
|
||||||
|
String name
|
||||||
|
) {}
|
|
@ -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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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> {
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
|
@ -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.CompoundGymId;
|
||||||
import nl.andrewlalis.gymboard_api.controller.dto.ExerciseSubmissionPayload;
|
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.CityRepository;
|
||||||
import nl.andrewlalis.gymboard_api.dao.CountryRepository;
|
import nl.andrewlalis.gymboard_api.dao.CountryRepository;
|
||||||
import nl.andrewlalis.gymboard_api.dao.GymRepository;
|
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.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.Exercise;
|
||||||
import nl.andrewlalis.gymboard_api.model.exercise.ExerciseSubmission;
|
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.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.CSVFormat;
|
||||||
import org.apache.commons.csv.CSVRecord;
|
import org.apache.commons.csv.CSVRecord;
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
|
@ -39,19 +45,27 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
||||||
private final ExerciseRepository exerciseRepository;
|
private final ExerciseRepository exerciseRepository;
|
||||||
private final ExerciseSubmissionService submissionService;
|
private final ExerciseSubmissionService submissionService;
|
||||||
private final UploadService uploadService;
|
private final UploadService uploadService;
|
||||||
|
private final RoleRepository roleRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final UserService userService;
|
||||||
|
|
||||||
public SampleDataLoader(
|
public SampleDataLoader(
|
||||||
CountryRepository countryRepository,
|
CountryRepository countryRepository,
|
||||||
CityRepository cityRepository,
|
CityRepository cityRepository,
|
||||||
GymRepository gymRepository,
|
GymRepository gymRepository,
|
||||||
ExerciseRepository exerciseRepository,
|
ExerciseRepository exerciseRepository,
|
||||||
ExerciseSubmissionService submissionService, UploadService uploadService) {
|
ExerciseSubmissionService submissionService,
|
||||||
|
UploadService uploadService,
|
||||||
|
RoleRepository roleRepository, UserRepository userRepository, UserService userService) {
|
||||||
this.countryRepository = countryRepository;
|
this.countryRepository = countryRepository;
|
||||||
this.cityRepository = cityRepository;
|
this.cityRepository = cityRepository;
|
||||||
this.gymRepository = gymRepository;
|
this.gymRepository = gymRepository;
|
||||||
this.exerciseRepository = exerciseRepository;
|
this.exerciseRepository = exerciseRepository;
|
||||||
this.submissionService = submissionService;
|
this.submissionService = submissionService;
|
||||||
this.uploadService = uploadService;
|
this.uploadService = uploadService;
|
||||||
|
this.roleRepository = roleRepository;
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.userService = userService;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -122,6 +136,24 @@ public class SampleDataLoader implements ApplicationListener<ContextRefreshedEve
|
||||||
e.printStackTrace();
|
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 {
|
private void loadCsv(String csvName, Consumer<CSVRecord> recordConsumer) throws IOException {
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -8,3 +8,6 @@ spring.jpa.show-sql=false
|
||||||
|
|
||||||
spring.task.execution.pool.core-size=3
|
spring.task.execution.pool.core-size=3
|
||||||
spring.task.execution.pool.max-size=10
|
spring.task.execution.pool.max-size=10
|
||||||
|
|
||||||
|
app.auth.private-key-location=./private_key.der
|
||||||
|
|
||||||
|
|
Loading…
Reference in New Issue