Added users!

This commit is contained in:
Andrew Lalis 2023-01-30 10:07:28 +01:00
parent 1034f7e65a
commit 0e96d74203
21 changed files with 596 additions and 2 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,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);
}
}

View File

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

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

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

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

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