working on backend:
- started FileStore implementation - implemented placing cookies Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
@@ -11,6 +11,7 @@ repositories {
|
||||
dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.10.0')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
implementation 'org.json:json:20240303'
|
||||
}
|
||||
|
||||
test {
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.api;
|
||||
|
||||
import com.sun.net.httpserver.Headers;
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import java.util.Map;
|
||||
|
||||
public abstract class Cookie implements Map.Entry<String, String> {
|
||||
private final String key;
|
||||
private String value = null;
|
||||
|
||||
Cookie(String key, String value) {
|
||||
this.key = key;
|
||||
setValue(value);
|
||||
}
|
||||
|
||||
public <T extends Cookie> T addTo(Headers headers) {
|
||||
headers.add("Set-Cookie", "%s=%s".formatted(key, value));
|
||||
return (T)this;
|
||||
}
|
||||
|
||||
public <T extends Cookie> T addTo(HttpExchange ex) {
|
||||
return this.addTo(ex.getResponseHeaders());
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getKey() {
|
||||
return key;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String getValue() {
|
||||
return value;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String setValue(String s) {
|
||||
var oldVal = value;
|
||||
value = s;
|
||||
return oldVal;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.api;
|
||||
|
||||
public interface PasswordHasher<T> {
|
||||
public String hash(String password, String salt);
|
||||
public String salt(String hashedPassword);
|
||||
|
||||
public default boolean matches(String plaintextPassword, String hashedPassword) {
|
||||
return hash(plaintextPassword, salt(hashedPassword)).equals(hashedPassword);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.api;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpHandler;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
@@ -9,10 +11,14 @@ import java.util.Arrays;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.stream.Stream;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public abstract class PathHandler implements HttpHandler {
|
||||
private String path;
|
||||
public static final String CONTENT_TYPE = "Content-Type";
|
||||
public static final String JSON = "application/json";
|
||||
public static final String POST = "POST";
|
||||
|
||||
private String path;
|
||||
|
||||
public class Bond {
|
||||
Bond(String p) {
|
||||
@@ -35,15 +41,29 @@ public abstract class PathHandler implements HttpHandler {
|
||||
return path;
|
||||
}
|
||||
|
||||
public Optional<String> getHeader(HttpExchange ex, String key) {
|
||||
/******* begin of static methods *************/
|
||||
|
||||
public static String body(HttpExchange ex) throws IOException {
|
||||
return new String(ex.getRequestBody().readAllBytes(), UTF_8);
|
||||
}
|
||||
|
||||
public static Optional<String> getAuthToken(HttpExchange ex) {
|
||||
return getHeader(ex, "Authorization");
|
||||
}
|
||||
|
||||
public static Optional<String> getHeader(HttpExchange ex, String key) {
|
||||
return Optional.ofNullable(ex.getRequestHeaders().get(key)).map(List::stream).map(Stream::findFirst).orElse(Optional.empty());
|
||||
}
|
||||
|
||||
public Optional<String> language(HttpExchange ex) {
|
||||
public static JSONObject json(HttpExchange ex) throws IOException {
|
||||
return new JSONObject(body(ex));
|
||||
}
|
||||
|
||||
public static Optional<String> language(HttpExchange ex) {
|
||||
return getHeader(ex, "Accept-Language").map(s -> Arrays.stream(s.split(","))).map(Stream::findFirst).orElse(Optional.empty());
|
||||
}
|
||||
|
||||
public void emptyResponse(int statusCode, HttpExchange ex) throws IOException {
|
||||
public static void sendEmptyResponse(int statusCode, HttpExchange ex) throws IOException {
|
||||
ex.sendResponseHeaders(statusCode, 0);
|
||||
ex.getResponseBody().close();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.api;
|
||||
|
||||
|
||||
public class SessionToken extends Cookie {
|
||||
public SessionToken(String value) {
|
||||
super("sessionToken", value);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,90 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.api;
|
||||
|
||||
public class User {}
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
|
||||
public final class User {
|
||||
public static final String EMAIL = "email";
|
||||
public static final String PASSWORD = "password";
|
||||
public static final String REALNAME = "realname";
|
||||
public static final String USERNAME = "username";
|
||||
|
||||
private String email, hashedPassword, realName, uuid, username;
|
||||
|
||||
public User(String username, String hashedPassword, String realName, String email, String uuid) {
|
||||
this.username = username;
|
||||
this.realName = realName;
|
||||
this.email = email;
|
||||
this.hashedPassword = hashedPassword;
|
||||
this.uuid = uuid;
|
||||
}
|
||||
|
||||
public String email() {
|
||||
return email;
|
||||
}
|
||||
|
||||
public User email(String newVal) {
|
||||
email = newVal;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean equals(Object obj) {
|
||||
if (obj == this) return true;
|
||||
if (obj == null || obj.getClass() != this.getClass()) return false;
|
||||
var that = (User)obj;
|
||||
return Objects.equals(this.uuid, that.uuid);
|
||||
}
|
||||
|
||||
public String hashedPassword() {
|
||||
return hashedPassword;
|
||||
}
|
||||
|
||||
public User hashedPassword(String newValue) {
|
||||
hashedPassword = newValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int hashCode() {
|
||||
return Objects.hash(username, realName, email, hashedPassword, uuid);
|
||||
}
|
||||
|
||||
|
||||
public Map<String, String> map(boolean includePassword) {
|
||||
return includePassword ? Map.of(USERNAME, username, REALNAME, realName, PASSWORD, hashedPassword, EMAIL, email) : Map.of(USERNAME, username, REALNAME, realName, EMAIL, email);
|
||||
}
|
||||
|
||||
public String realName() {
|
||||
return realName;
|
||||
}
|
||||
|
||||
public User realName(String newValue) {
|
||||
realName = newValue;
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String toString() {
|
||||
return "User["
|
||||
+ "username=" + username + ", "
|
||||
+ "realName=" + realName + ", "
|
||||
+ "email=" + email + ", "
|
||||
+ "uuid=" + uuid + ']';
|
||||
}
|
||||
|
||||
public String username() {
|
||||
return username;
|
||||
}
|
||||
|
||||
public User username(String newVal) {
|
||||
username = newVal;
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
public String uuid() {
|
||||
return uuid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.api;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
public interface UserService {
|
||||
public UserService delete(User user);
|
||||
public UserService init(User defaultUser);
|
||||
public List<User> list();
|
||||
public Optional<User> load(String username, String password);
|
||||
public UserService save(User user);
|
||||
}
|
||||
@@ -15,6 +15,7 @@ dependencies {
|
||||
implementation project(':de.srsoftware.oidc.api')
|
||||
implementation project(':de.srsoftware.oidc.backend')
|
||||
implementation project(':de.srsoftware.oidc.web')
|
||||
implementation project(':de.srsoftware.oidc.datastore.file')
|
||||
}
|
||||
|
||||
test {
|
||||
|
||||
@@ -3,21 +3,35 @@ package de.srsoftware.oidc.app;
|
||||
|
||||
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import de.srsoftware.oidc.api.User;
|
||||
import de.srsoftware.oidc.api.UserService;
|
||||
import de.srsoftware.oidc.backend.Backend;
|
||||
import de.srsoftware.oidc.datastore.file.FileStore;
|
||||
import de.srsoftware.oidc.datastore.file.UuidHasher;
|
||||
import de.srsoftware.oidc.web.Forward;
|
||||
import de.srsoftware.oidc.web.StaticPages;
|
||||
import java.io.File;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.util.UUID;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
public class Application {
|
||||
public static final String STATIC_PATH = "/web";
|
||||
public static final String INDEX = STATIC_PATH + "/index.html";
|
||||
public static final String STATIC_PATH = "/web";
|
||||
public static final String FIRST_USER = "admin";
|
||||
public static final String FIRST_USER_PASS = "admin";
|
||||
public static final String FIRST_UUID = UUID.randomUUID().toString();
|
||||
public static final String INDEX = STATIC_PATH + "/index.html";
|
||||
|
||||
public static void main(String[] args) throws Exception {
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
|
||||
var storageFile = new File("/tmp/lightoidc.json");
|
||||
var passwordHasher = new UuidHasher();
|
||||
var firstHash = passwordHasher.hash(FIRST_USER_PASS, FIRST_UUID);
|
||||
var firstUser = new User(FIRST_USER, firstHash, FIRST_USER, "%s@internal".formatted(FIRST_USER), FIRST_UUID);
|
||||
UserService userService = new FileStore(storageFile, passwordHasher).init(firstUser);
|
||||
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0);
|
||||
new StaticPages().bindPath(STATIC_PATH).on(server);
|
||||
new Forward(INDEX).bindPath("/").on(server);
|
||||
new Backend().bindPath("/api").on(server);
|
||||
new Backend(userService).bindPath("/api").on(server);
|
||||
server.setExecutor(Executors.newCachedThreadPool());
|
||||
server.start();
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.10.0')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
implementation project(':de.srsoftware.oidc.api')
|
||||
implementation 'org.json:json:20240303'
|
||||
}
|
||||
|
||||
test {
|
||||
|
||||
@@ -1,28 +1,56 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.backend;
|
||||
|
||||
import static de.srsoftware.oidc.api.User.PASSWORD;
|
||||
import static de.srsoftware.oidc.api.User.USERNAME;
|
||||
import static java.net.HttpURLConnection.HTTP_NOT_FOUND;
|
||||
import static java.net.HttpURLConnection.HTTP_UNAUTHORIZED;
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
import static java.util.jar.Attributes.Name.CONTENT_TYPE;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import de.srsoftware.oidc.api.PathHandler;
|
||||
import de.srsoftware.oidc.api.SessionToken;
|
||||
import de.srsoftware.oidc.api.User;
|
||||
import de.srsoftware.oidc.api.UserService;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class Backend extends PathHandler {
|
||||
private final UserService users;
|
||||
|
||||
public Backend(UserService userService) {
|
||||
users = userService;
|
||||
}
|
||||
|
||||
private void doLogin(HttpExchange ex) throws IOException {
|
||||
var body = json(ex);
|
||||
|
||||
var username = body.has(USERNAME) ? body.getString(USERNAME) : null;
|
||||
var password = body.has(PASSWORD) ? body.getString(PASSWORD) : null;
|
||||
|
||||
Optional<User> user = users.load(username, password);
|
||||
if (user.isPresent()) {
|
||||
sendUserAndCookie(ex, user.get());
|
||||
return;
|
||||
}
|
||||
sendEmptyResponse(HTTP_UNAUTHORIZED, ex);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void handle(HttpExchange ex) throws IOException {
|
||||
String path = relativePath(ex);
|
||||
String method = ex.getRequestMethod();
|
||||
System.out.printf("%s %s…", method, path);
|
||||
|
||||
if ("login".equals(path)) {
|
||||
doLogin(ex); // TODO: prevent brute force
|
||||
if ("login".equals(path) && POST.equals(method)) {
|
||||
doLogin(ex); // TODO: prevent brute force
|
||||
return;
|
||||
}
|
||||
var token = getAuthToken(ex);
|
||||
if (token.isEmpty()) {
|
||||
emptyResponse(HTTP_UNAUTHORIZED, ex);
|
||||
sendEmptyResponse(HTTP_UNAUTHORIZED, ex);
|
||||
System.err.println("unauthorized");
|
||||
return;
|
||||
}
|
||||
@@ -31,14 +59,13 @@ public class Backend extends PathHandler {
|
||||
ex.getResponseBody().close();
|
||||
}
|
||||
|
||||
private void doLogin(HttpExchange ex) throws IOException {
|
||||
Optional<String> user = getHeader(ex, "login-username");
|
||||
Optional<String> pass = getHeader(ex, "login-password");
|
||||
System.out.printf("%s : %s", user, pass);
|
||||
emptyResponse(HTTP_UNAUTHORIZED, ex);
|
||||
}
|
||||
private void sendUserAndCookie(HttpExchange ex, User user) throws IOException {
|
||||
var bytes = new JSONObject(user.map(false)).toString().getBytes(UTF_8);
|
||||
var headers = ex.getResponseHeaders();
|
||||
|
||||
private Optional<String> getAuthToken(HttpExchange ex) {
|
||||
return getHeader(ex, "Authorization");
|
||||
headers.add(CONTENT_TYPE, JSON);
|
||||
new SessionToken("Test").addTo(headers);
|
||||
ex.sendResponseHeaders(200, bytes.length);
|
||||
ex.getResponseBody().write(bytes);
|
||||
}
|
||||
}
|
||||
|
||||
21
de.srsoftware.oidc.datastore.file/build.gradle
Normal file
21
de.srsoftware.oidc.datastore.file/build.gradle
Normal file
@@ -0,0 +1,21 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
}
|
||||
|
||||
group = 'de.srsoftware'
|
||||
version = '1.0-SNAPSHOT'
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
testImplementation platform('org.junit:junit-bom:5.10.0')
|
||||
testImplementation 'org.junit.jupiter:junit-jupiter'
|
||||
implementation project(':de.srsoftware.oidc.api')
|
||||
implementation 'org.json:json:20240303'
|
||||
}
|
||||
|
||||
test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.datastore.file; /* © SRSoftware 2024 */
|
||||
import static de.srsoftware.oidc.api.User.*;
|
||||
|
||||
import de.srsoftware.oidc.api.PasswordHasher;
|
||||
import de.srsoftware.oidc.api.User;
|
||||
import de.srsoftware.oidc.api.UserService;
|
||||
import java.io.File;
|
||||
import java.io.FileNotFoundException;
|
||||
import java.io.IOException;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class FileStore implements UserService {
|
||||
private static final String USERS = "users";
|
||||
|
||||
private final Path storageFile;
|
||||
private final JSONObject json;
|
||||
private final PasswordHasher<String> passwordHasher;
|
||||
|
||||
public FileStore(File storage, PasswordHasher<String> passwordHasher) throws IOException {
|
||||
this.storageFile = storage.toPath();
|
||||
this.passwordHasher = passwordHasher;
|
||||
|
||||
if (!storage.exists()) {
|
||||
var parent = storage.getParentFile();
|
||||
if (!parent.exists() && !parent.mkdirs()) throw new FileNotFoundException("Failed to create directory %s".formatted(parent));
|
||||
Files.writeString(storageFile, "{}");
|
||||
}
|
||||
json = new JSONObject(Files.readString(storageFile));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Optional<User> load(String username, String password) {
|
||||
try {
|
||||
var users = json.getJSONObject(USERS);
|
||||
var uuids = users.keySet();
|
||||
for (String uuid : uuids) {
|
||||
var user = users.getJSONObject(uuid);
|
||||
if (!user.getString(USERNAME).equals(username)) continue;
|
||||
var hashedPass = user.getString(PASSWORD);
|
||||
if (passwordHasher.matches(password, hashedPass)) {
|
||||
return Optional.of(new User(username, hashedPass, user.getString(REALNAME), user.getString(EMAIL), uuid));
|
||||
}
|
||||
}
|
||||
return Optional.empty();
|
||||
} catch (Exception e) {
|
||||
return Optional.empty();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserService delete(User user) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserService init(User defaultUser) {
|
||||
if (!json.has(USERS)) save(defaultUser);
|
||||
return this;
|
||||
}
|
||||
|
||||
@Override
|
||||
public UserService save(User user) {
|
||||
JSONObject users;
|
||||
if (!json.has(USERS)) {
|
||||
json.put(USERS, users = new JSONObject());
|
||||
} else
|
||||
users = json.getJSONObject(USERS);
|
||||
users.put(user.uuid(), user.map(true));
|
||||
try {
|
||||
Files.writeString(storageFile, json.toString(2));
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException(e);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
|
||||
@Override
|
||||
public List<User> list() {
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/* © SRSoftware 2024 */
|
||||
package de.srsoftware.oidc.datastore.file;
|
||||
|
||||
import static java.nio.charset.StandardCharsets.UTF_8;
|
||||
|
||||
import de.srsoftware.oidc.api.PasswordHasher;
|
||||
import java.security.MessageDigest;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
|
||||
|
||||
public class UuidHasher implements PasswordHasher<String> {
|
||||
private static final String SHA256 = "SHA-256";
|
||||
|
||||
private final MessageDigest digest;
|
||||
|
||||
public UuidHasher() throws NoSuchAlgorithmException {
|
||||
digest = MessageDigest.getInstance(SHA256);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String hash(String password, String uuid) {
|
||||
var salt = uuid;
|
||||
var saltedPass = "%s %s".formatted(salt, password);
|
||||
var bytes = digest.digest(saltedPass.getBytes(UTF_8));
|
||||
|
||||
return "%s@%s".formatted(hex(bytes), salt);
|
||||
}
|
||||
|
||||
@Override
|
||||
public String salt(String hashedPassword) {
|
||||
return hashedPassword.split("@")[1];
|
||||
}
|
||||
|
||||
public static String hex(byte[] bytes) {
|
||||
StringBuilder sb = new StringBuilder(bytes.length * 2);
|
||||
for (byte b : bytes) sb.append(String.format("%02x", b));
|
||||
return sb.toString();
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,7 @@ public class StaticPages extends PathHandler {
|
||||
System.out.printf("Loading %s for lagnuage %s…", path, lang);
|
||||
var response = loadTemplate(lang, path).orElseThrow(() -> new FileNotFoundException());
|
||||
|
||||
ex.getResponseHeaders().add("Content-Type", response.contentType);
|
||||
ex.getResponseHeaders().add(CONTENT_TYPE, response.contentType);
|
||||
ex.sendResponseHeaders(200, response.content.length);
|
||||
OutputStream os = ex.getResponseBody();
|
||||
os.write(response.content);
|
||||
|
||||
@@ -33,11 +33,11 @@ function tryLogin(){
|
||||
document.getElementById("error").innerHTML = "";
|
||||
var data = Object.fromEntries(new FormData(document.getElementById('login')));
|
||||
fetch(api+"/login",{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'login-username': data.user,
|
||||
'login-password': data.pass, // TODO: send via body?
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
}).then(handleLogin);
|
||||
}
|
||||
@@ -11,11 +11,11 @@
|
||||
<legend>User credentials</legend>
|
||||
<label>
|
||||
Username
|
||||
<input type="text" name="user" />
|
||||
<input type="text" name="username" />
|
||||
</label>
|
||||
<label>
|
||||
Password
|
||||
<input type="password" name="pass" />
|
||||
<input type="password" name="password" />
|
||||
</label>
|
||||
<button type="button" onClick="tryLogin()">Login</button>
|
||||
</fieldset>
|
||||
|
||||
@@ -3,4 +3,5 @@ include 'de.srsoftware.oidc.api'
|
||||
include 'de.srsoftware.oidc.app'
|
||||
include 'de.srsoftware.oidc.web'
|
||||
include 'de.srsoftware.oidc.backend'
|
||||
include 'de.srsoftware.oidc.datastore.file'
|
||||
|
||||
|
||||
Reference in New Issue
Block a user