Compare commits

..

2 Commits

Author SHA1 Message Date
c3dfab19ea Added docker compose 2025-10-05 15:10:41 +02:00
abb89dbc6e Finished implementation 2025-10-05 14:45:32 +02:00
14 changed files with 496 additions and 16 deletions

View File

@ -4,7 +4,6 @@
<option name="SPRING_BOOT_MAIN_CLASS" value="at.nanopenguin.fhtw.itse.pw_demo.PwDemoApplication" /> <option name="SPRING_BOOT_MAIN_CLASS" value="at.nanopenguin.fhtw.itse.pw_demo.PwDemoApplication" />
<method v="2"> <method v="2">
<option name="Make" enabled="true" /> <option name="Make" enabled="true" />
<option name="RunConfigurationTask" enabled="true" run_configuration_name="Docker Image" run_configuration_type="docker-deploy" />
</method> </method>
</configuration> </configuration>
</component> </component>

19
.run/Setup tables.run.xml Normal file
View File

@ -0,0 +1,19 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="Setup tables" type="DatabaseScript">
<data-source id="1c5ced7c-10f3-4662-9fcf-f6601b0cfc94" />
<script-text>CREATE TABLE users (
username VARCHAR(50) NOT NULL PRIMARY KEY,
password VARCHAR(500) NOT NULL,
enabled BOOLEAN NOT NULL
);
CREATE TABLE authorities (
username VARCHAR(50) NOT NULL,
authority VARCHAR(50) NOT NULL,
CONSTRAINT fk_authorities_users FOREIGN KEY (username) REFERENCES users(username)
);
CREATE UNIQUE INDEX ix_auth_username ON authorities (username, authority);</script-text>
<method v="2" />
</configuration>
</component>

18
Dockerfile Normal file
View File

@ -0,0 +1,18 @@
FROM maven:4.0.0-rc-4-eclipse-temurin-21-alpine AS build
WORKDIR /app
# separate dependency stage to reduce build time when dependencies are unchanged
COPY pom.xml .
RUN mvn dependency:go-offline -B
COPY src ./src
# issues when not skipping, idk why
RUN mvn clean package -DskipTests
# openjdk is now deprecated over temurin
FROM eclipse-temurin:21-jre-alpine
WORKDIR /app
COPY --from=build /app/target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

45
docker-compose.yml Normal file
View File

@ -0,0 +1,45 @@
services:
postgres:
image: postgres:18-alpine
container_name: itse-b08-database
environment:
POSTGRES_DB: pw_demo
POSTGRES_USER: pw_demo
POSTGRES_PASSWORD: pw_demo
ports:
- "5432:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./init-scripts:/docker-entrypoint-initdb.d
healthcheck:
test: ["CMD-SHELL", "pg_isready -U myuser -d myappdb"]
interval: 10s
timeout: 5s
retries: 5
networks:
- app-network
app:
build:
context: .
dockerfile: Dockerfile
container_name: itse-b08-spring
depends_on:
postgres:
condition: service_healthy
environment:
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/pw_demo
SPRING_DATASOURCE_USERNAME: pw_demo
SPRING_DATASOURCE_PASSWORD: pw_demo
SPRING_JPA_HIBERNATE_DDL_AUTO: update
ports:
- "8080:8080"
networks:
- app-network
volumes:
postgres_data:
networks:
app-network:
driver: bridge

13
init-scripts/init.sql Normal file
View File

@ -0,0 +1,13 @@
CREATE TABLE IF NOT EXISTS users (
username VARCHAR(50) NOT NULL PRIMARY KEY,
password VARCHAR(500) NOT NULL,
enabled BOOLEAN NOT NULL
);
CREATE TABLE IF NOT EXISTS authorities (
username VARCHAR(50) NOT NULL,
authority VARCHAR(50) NOT NULL,
CONSTRAINT fk_authorities_users FOREIGN KEY (username) REFERENCES users(username)
);
CREATE UNIQUE INDEX IF NOT EXISTS ix_auth_username ON authorities (username, authority);

View File

@ -40,7 +40,7 @@ Note: Die angeführte Struktur ist die Langfassung von Punkten, die ich in irgen
- Character-Set (Buchstaben (klein/groß), Zahlen, (welche) Sonderzeichen?) - Character-Set (Buchstaben (klein/groß), Zahlen, (welche) Sonderzeichen?)
- Wörter & Namen? → Wenn ja, Tauschregeln von Buchstaben (0 ↔ O, 1 ↔ l ↔ I ↔ !) - Wörter & Namen? → Wenn ja, Tauschregeln von Buchstaben (0 ↔ O, 1 ↔ l ↔ I ↔ !)
### Salt & Pepper ### Salt
- "Gratis" Entropy, am Server angehängt - "Gratis" Entropy, am Server angehängt
- Salt: Unique pro Nutzer - Salt: Unique pro Nutzer
@ -50,6 +50,12 @@ Note: Die angeführte Struktur ist die Langfassung von Punkten, die ich in irgen
- Tunable CPU & Memory usage um Brute-Force zu erschweren - Tunable CPU & Memory usage um Brute-Force zu erschweren
- Beispiele (Pbkdf, bcrypt, scrypt, ...) - Beispiele (Pbkdf, bcrypt, scrypt, ...)
#### PBKDF2
- key = pbkdf2(password, salt, iterations-count, hash-function, derived-key-len)
-
- (Überleitung zu Demo) - (Überleitung zu Demo)
### Demo ### Demo

View File

@ -0,0 +1,115 @@
package at.nanopenguin.fhtw.itse.pw_demo;
import lombok.SneakyThrows;
import org.springframework.boot.web.servlet.server.Encoding;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.crypto.util.EncodingUtils;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.util.Base64;
import java.util.function.Function;
public class CustomPbkdf2PasswordEncoder implements PasswordEncoder {
private static final int SALT_LENGTH = 16; // 128 bits
private static final int ITERATIONS = 1024;
private final SecureRandom random = new SecureRandom();
@Override
public String encode(CharSequence rawPassword) {
byte[] salt = generateSalt();
byte[] hash = pbkdf2(
rawPassword.toString().getBytes(StandardCharsets.UTF_8),
salt,
ITERATIONS
);
return Base64.getEncoder().encodeToString(salt) + ":" + Base64.getEncoder().encodeToString(hash);
}
private byte[] generateSalt() {
byte[] salt = new byte[SALT_LENGTH];
random.nextBytes(salt);
return salt;
}
private byte[] pbkdf2(byte[] password, byte[] salt, int iterations) {
byte[] saltWithBlockNum = new byte[salt.length + 4];
System.arraycopy(salt, 0, saltWithBlockNum, 0, salt.length);
saltWithBlockNum[salt.length + 3] = 1;
byte[] u = hmac(password, saltWithBlockNum);
byte[] result = u.clone();
for (int i = 2; i <= iterations; i++) {
u = hmac(password, u);
xorInPlace(result, u);
}
return result;
}
private void xorInPlace(byte[] a, byte[] b) {
for (int i = 0; i < a.length; i++) {
a[i] ^= b[i];
}
}
private byte[] hmac(byte[] key, byte[] message) {
try {
MessageDigest sha256 = MessageDigest.getInstance("SHA-256");
// Key preparation
byte[] k = key;
if (key.length > 64) {
k = sha256.digest(key);
sha256.reset();
}
byte[] keyPadded = new byte[64];
System.arraycopy(k, 0, keyPadded, 0, k.length);
// Inner and outer padding
byte[] innerPad = new byte[64];
byte[] outerPad = new byte[64];
for (int i = 0; i < 64; i++) {
innerPad[i] = (byte) (keyPadded[i] ^ 0x36);
outerPad[i] = (byte) (keyPadded[i] ^ 0x5C);
}
// HMAC = SHA256(outerPad || SHA256(innerPad || message))
sha256.update(innerPad);
sha256.update(message);
byte[] innerHash = sha256.digest();
sha256.reset();
sha256.update(outerPad);
sha256.update(innerHash);
return sha256.digest();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
String[] parts = encodedPassword.split(":");
byte[] salt = Base64.getDecoder().decode(parts[0]);
byte[] storedHash = Base64.getDecoder().decode(parts[1]);
byte[] testHash = pbkdf2(rawPassword.toString().getBytes(StandardCharsets.UTF_8), salt, ITERATIONS);
int diff = 0;
for (int i = 0; i < 32; i++) {
diff |= storedHash[i] ^ testHash[i];
}
return diff == 0;
}
}

View File

@ -0,0 +1,38 @@
package at.nanopenguin.fhtw.itse.pw_demo;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PostMapping;
import java.util.Collections;
@Controller
@RequiredArgsConstructor
public class RegistrationController {
private final JdbcUserDetailsManager userDetailsService;
private final SelectablePasswordEncoder encoder;
@GetMapping("/register")
public String showRegistrationForm(Model model) {
model.addAttribute("user", new UserDTO());
return "register";
}
@PostMapping("/register")
public String registerUser(@ModelAttribute UserDTO user, Model model) {
userDetailsService.createUser(
new User(user.getName(), encoder.encodeSelect(user.getPassword(), user.getEncoder()), Collections.singleton(new SimpleGrantedAuthority("USER")))
);
model.addAttribute("user", new UserDTO());
model.addAttribute("message", "User registered successfully!");
return "register";
}
}

View File

@ -0,0 +1,28 @@
package at.nanopenguin.fhtw.itse.pw_demo;
import lombok.SneakyThrows;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.Base64;
public class SHA256PasswordEncoder implements PasswordEncoder {
MessageDigest encoder;
@SneakyThrows // just a demo, so we can throw best practices out of the window :)
public SHA256PasswordEncoder() {
encoder = MessageDigest.getInstance("SHA-256");
}
@Override
public String encode(CharSequence rawPassword) {
return Base64.getEncoder().encodeToString(encoder.digest(rawPassword.toString().getBytes(StandardCharsets.UTF_8)));
}
@Override
public boolean matches(CharSequence rawPassword, String encodedPassword) {
return String.valueOf(this.encode(rawPassword)).equals(encodedPassword);
}
}

View File

@ -18,6 +18,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.*; import org.springframework.security.crypto.password.*;
import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder; import org.springframework.security.crypto.scrypt.SCryptPasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager; import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.provisioning.JdbcUserDetailsManager;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import javax.sql.DataSource; import javax.sql.DataSource;
@ -35,6 +36,7 @@ public class SecurityConfig {
http http
.formLogin(Customizer.withDefaults()) .formLogin(Customizer.withDefaults())
.authorizeHttpRequests(authorize -> authorize .authorizeHttpRequests(authorize -> authorize
.requestMatchers("/register").permitAll()
.anyRequest().authenticated() .anyRequest().authenticated()
); );
@ -42,25 +44,24 @@ public class SecurityConfig {
} }
@Bean @Bean
public UserDetailsService userDetailsService(PasswordEncoder passwordEncoder) { public JdbcUserDetailsManager jdbcUserDetailsManager(DataSource dataSource) {
UserDetailsService userDetailsService = new InMemoryUserDetailsManager( // TODO: JDBC return new JdbcUserDetailsManager(dataSource);
new User("Beni", passwordEncoder.encode("password"), Set.of(new SimpleGrantedAuthority("USER")))
);
return userDetailsService;
} }
@Bean @Bean
public PasswordEncoder passwordEncoder() { public SelectablePasswordEncoder passwordEncoder() {
String defaultEncoder = "pbkdf2"; String defaultEncoder = "pbkdf2";
Map<String, PasswordEncoder> encoders = new HashMap<>(); Map<String, PasswordEncoder> encoders = new HashMap<>();
encoders.put("noop", NoOpPasswordEncoder.getInstance()); encoders.put("noop", NoOpPasswordEncoder.getInstance());
encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("sha256", new SHA256PasswordEncoder());
encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("md5", new MessageDigestPasswordEncoder("md5"));
encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()); encoders.put("pbkdf2", new CustomPbkdf2PasswordEncoder());
encoders.put("bcrypt", new BCryptPasswordEncoder()); //encoders.put("pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
encoders.put("sha256", new StandardPasswordEncoder()); //encoders.put("scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8());
return new DelegatingPasswordEncoder(defaultEncoder, encoders); //encoders.put("argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8());
//encoders.put("bcrypt", new BCryptPasswordEncoder());
//encoders.put("sha256", new StandardPasswordEncoder());
return new SelectablePasswordEncoder(defaultEncoder, encoders);
} }
} }

View File

@ -0,0 +1,144 @@
package at.nanopenguin.fhtw.itse.pw_demo;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.HashMap;
import java.util.Map;
public class SelectablePasswordEncoder implements PasswordEncoder {
private static final String DEFAULT_ID_PREFIX = "{";
private static final String DEFAULT_ID_SUFFIX = "}";
private static final String NO_PASSWORD_ENCODER_MAPPED = "There is no password encoder mapped for the id '%s'. Check your configuration to ensure it matches one of the registered encoders.";
private static final String NO_PASSWORD_ENCODER_PREFIX = "Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `SelectablePasswordEncoder`.";
private static final String MALFORMED_PASSWORD_ENCODER_PREFIX = "The name of the password encoder is improperly formatted or incomplete. The format should be '%sENCODER%spassword'.";
private final String idPrefix;
private final String idSuffix;
private final String idForEncode;
private final PasswordEncoder passwordEncoderForEncode;
private final Map<String, PasswordEncoder> idToPasswordEncoder;
private PasswordEncoder defaultPasswordEncoderForMatches;
public SelectablePasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder) {
this(idForEncode, idToPasswordEncoder, "{", "}");
}
public SelectablePasswordEncoder(String idForEncode, Map<String, PasswordEncoder> idToPasswordEncoder, String idPrefix, String idSuffix) {
this.defaultPasswordEncoderForMatches = new UnmappedIdPasswordEncoder();
if (idForEncode == null) {
throw new IllegalArgumentException("idForEncode cannot be null");
} else if (idPrefix == null) {
throw new IllegalArgumentException("prefix cannot be null");
} else if (idSuffix != null && !idSuffix.isEmpty()) {
if (idPrefix.contains(idSuffix)) {
throw new IllegalArgumentException("idPrefix " + idPrefix + " cannot contain idSuffix " + idSuffix);
} else if (!idToPasswordEncoder.containsKey(idForEncode)) {
throw new IllegalArgumentException("idForEncode " + idForEncode + "is not found in idToPasswordEncoder " + String.valueOf(idToPasswordEncoder));
} else {
for(String id : idToPasswordEncoder.keySet()) {
if (id != null) {
if (!idPrefix.isEmpty() && id.contains(idPrefix)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + idPrefix);
}
if (id.contains(idSuffix)) {
throw new IllegalArgumentException("id " + id + " cannot contain " + idSuffix);
}
}
}
this.idForEncode = idForEncode;
this.passwordEncoderForEncode = (PasswordEncoder)idToPasswordEncoder.get(idForEncode);
this.idToPasswordEncoder = new HashMap(idToPasswordEncoder);
this.idPrefix = idPrefix;
this.idSuffix = idSuffix;
}
} else {
throw new IllegalArgumentException("suffix cannot be empty");
}
}
public void setDefaultPasswordEncoderForMatches(PasswordEncoder defaultPasswordEncoderForMatches) {
if (defaultPasswordEncoderForMatches == null) {
throw new IllegalArgumentException("defaultPasswordEncoderForMatches cannot be null");
} else {
this.defaultPasswordEncoderForMatches = defaultPasswordEncoderForMatches;
}
}
public String encode(CharSequence rawPassword) {
String var10000 = this.idPrefix;
return var10000 + this.idForEncode + this.idSuffix + this.passwordEncoderForEncode.encode(rawPassword);
}
public String encodeSelect(CharSequence rawPassword, String encoder) {
String var10000 = this.idPrefix;;
return var10000 + encoder + this.idSuffix + this.idToPasswordEncoder.get(encoder).encode(rawPassword);
}
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
if (rawPassword == null && prefixEncodedPassword == null) {
return true;
} else {
String id = this.extractId(prefixEncodedPassword);
PasswordEncoder delegate = (PasswordEncoder)this.idToPasswordEncoder.get(id);
if (delegate == null) {
return this.defaultPasswordEncoderForMatches.matches(rawPassword, prefixEncodedPassword);
} else {
String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword);
return delegate.matches(rawPassword, encodedPassword);
}
}
}
private String extractId(String prefixEncodedPassword) {
if (prefixEncodedPassword == null) {
return null;
} else {
int start = prefixEncodedPassword.indexOf(this.idPrefix);
if (start != 0) {
return null;
} else {
int end = prefixEncodedPassword.indexOf(this.idSuffix, start);
return end < 0 ? null : prefixEncodedPassword.substring(start + this.idPrefix.length(), end);
}
}
}
public boolean upgradeEncoding(String prefixEncodedPassword) {
String id = this.extractId(prefixEncodedPassword);
if (!this.idForEncode.equalsIgnoreCase(id)) {
return true;
} else {
String encodedPassword = this.extractEncodedPassword(prefixEncodedPassword);
return ((PasswordEncoder)this.idToPasswordEncoder.get(id)).upgradeEncoding(encodedPassword);
}
}
private String extractEncodedPassword(String prefixEncodedPassword) {
int start = prefixEncodedPassword.indexOf(this.idSuffix);
return prefixEncodedPassword.substring(start + this.idSuffix.length());
}
private class UnmappedIdPasswordEncoder implements PasswordEncoder {
public String encode(CharSequence rawPassword) {
throw new UnsupportedOperationException("encode is not supported");
}
public boolean matches(CharSequence rawPassword, String prefixEncodedPassword) {
String id = SelectablePasswordEncoder.this.extractId(prefixEncodedPassword);
if (id != null && !id.isBlank()) {
throw new IllegalArgumentException(String.format("There is no password encoder mapped for the id '%s'. Check your configuration to ensure it matches one of the registered encoders.", id));
} else {
if (prefixEncodedPassword != null && !prefixEncodedPassword.isBlank()) {
int start = prefixEncodedPassword.indexOf(SelectablePasswordEncoder.this.idPrefix);
int end = prefixEncodedPassword.indexOf(SelectablePasswordEncoder.this.idSuffix, start);
if (start < 0 && end < 0) {
throw new IllegalArgumentException("Given that there is no default password encoder configured, each password must have a password encoding prefix. Please either prefix this password with '{noop}' or set a default password encoder in `SelectablePasswordEncoder`.");
}
}
throw new IllegalArgumentException(String.format("The name of the password encoder is improperly formatted or incomplete. The format should be '%sENCODER%spassword'.", SelectablePasswordEncoder.this.idPrefix, SelectablePasswordEncoder.this.idSuffix));
}
}
}
}

View File

@ -0,0 +1,12 @@
package at.nanopenguin.fhtw.itse.pw_demo;
import lombok.Getter;
import lombok.Setter;
@Getter
@Setter
public class UserDTO {
private String name;
private String password;
private String encoder;
}

View File

@ -17,5 +17,4 @@ public class WelcomeController {
return "welcome"; return "welcome";
} }
} }

View File

@ -0,0 +1,43 @@
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org" lang="en">
<head>
<title>Welcome!</title>
<link href="/default-ui.css" rel="stylesheet">
</head>
<body>
<div class="content">
<h2>Register User</h2>
<form th:action="@{/register}" th:object="${user}" method="post">
<div>
<label for="name">Name:</label>
<input type="text" id="name" th:field="*{name}" required>
</div>
<div>
<label for="password">Password:</label>
<input type="password" id="password" th:field="*{password}" required>
</div>
<div>
<label for="encoder">Password Encoder:</label>
<select id="encoder" th:field="*{encoder}" required>
<option value="">-- Select Encoder --</option>
<option value="pbkdf2">PBKDF2</option>
<option value="sha256">Unsalted SHA256</option>
<option value="md5">MD5</option>
<option value="noop">NoOp (Plain Text)</option>
</select>
</div>
<div>
<button type="submit">Register</button>
</div>
</form>
<div th:if="${message}">
<p th:text="${message}"></p>
</div>
</div>
</body>
</html>