diff --git a/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java b/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java index 2f63002..0a71049 100644 --- a/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java +++ b/de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java @@ -16,15 +16,16 @@ import java.util.stream.Stream; import org.json.JSONObject; public abstract class PathHandler implements HttpHandler { - public static final String AUTHORIZATION = "Authorization"; - public static final String CONTENT_TYPE = "Content-Type"; - public static final String DELETE = "DELETE"; - private static final String FORWARDED_HOST = "x-forwarded-host"; - public static final String GET = "GET"; - public static final String HOST = "host"; - public static final String JSON = "application/json"; - public static System.Logger LOG = System.getLogger(PathHandler.class.getSimpleName()); - public static final String POST = "POST"; + public static final String AUTHORIZATION = "Authorization"; + public static final String CONTENT_TYPE = "Content-Type"; + public static final String DEFAULT_LANGUAGE = "en"; + public static final String DELETE = "DELETE"; + private static final String FORWARDED_HOST = "x-forwarded-host"; + public static final String GET = "GET"; + public static final String HOST = "host"; + public static final String JSON = "application/json"; + public static System.Logger LOG = System.getLogger(PathHandler.class.getSimpleName()); + public static final String POST = "POST"; private String[] paths; @@ -35,9 +36,9 @@ public abstract class PathHandler implements HttpHandler { Bond(String[] paths) { PathHandler.this.paths = paths; } - public HttpServer on(HttpServer server) { + public PathHandler on(HttpServer server) { for (var path : paths) server.createContext(path, PathHandler.this); - return server; + return PathHandler.this; } } @@ -132,8 +133,11 @@ public abstract class PathHandler implements HttpHandler { return new JSONObject(body(ex)); } - public static Optional language(HttpExchange ex) { - return getHeader(ex, "Accept-Language").map(s -> Arrays.stream(s.split(","))).flatMap(Stream::findFirst); + public static String language(HttpExchange ex) { + return getHeader(ex, "Accept-Language") // + .map(s -> Arrays.stream(s.split(","))) + .flatMap(Stream::findFirst) + .orElse(DEFAULT_LANGUAGE); } public static boolean notFound(HttpExchange ex) throws IOException { @@ -172,4 +176,8 @@ public abstract class PathHandler implements HttpHandler { public static boolean sendContent(HttpExchange ex, Object o) throws IOException { return sendContent(ex, HTTP_OK, o); } + + public static String url(HttpExchange ex) { + return hostname(ex) + ex.getRequestURI(); + } } diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/ResourceLoader.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/ResourceLoader.java new file mode 100644 index 0000000..b079ba8 --- /dev/null +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/ResourceLoader.java @@ -0,0 +1,10 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.oidc.api; + +import java.util.Optional; + +public interface ResourceLoader { + public static record Resource(String contentType, byte[] content) { + } + Optional loadFile(String lang, String path); +} diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java index 9347ac3..809574b 100644 --- a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java @@ -1,6 +1,7 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.api; +import de.srsoftware.oidc.api.data.AccessToken; import de.srsoftware.oidc.api.data.User; import java.util.List; import java.util.Optional; @@ -12,7 +13,7 @@ public interface UserService { * @param user * @return */ - public String accessToken(User user); + public AccessToken accessToken(User user); public UserService delete(User user); /** diff --git a/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/AccessToken.java b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/AccessToken.java new file mode 100644 index 0000000..8751222 --- /dev/null +++ b/de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/AccessToken.java @@ -0,0 +1,10 @@ +/* © SRSoftware 2024 */ +package de.srsoftware.oidc.api.data; + +import java.time.Instant; + +public record AccessToken(String id, User user, Instant expiration) { + public boolean valid() { + return Instant.now().isBefore(expiration); + } +} diff --git a/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java b/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java index 970217a..4c626a5 100644 --- a/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java +++ b/de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java @@ -58,10 +58,10 @@ public class Application { KeyManager keyManager = new RotatingKeyManager(keyStore); FileStore fileStore = new FileStore(storageFile, passwordHasher).init(firstUser); HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); - new StaticPages(basePath).bindPath(STATIC_PATH, FAVICON).on(server); + var staticPages = (StaticPages) new StaticPages(basePath).bindPath(STATIC_PATH, FAVICON).on(server); new Forward(INDEX).bindPath(ROOT).on(server); new WellKnownController().bindPath(WELL_KNOWN).on(server); - new UserController(fileStore, fileStore, fileStore).bindPath(API_USER).on(server); + new UserController(fileStore, fileStore, fileStore, staticPages).bindPath(API_USER).on(server); var tokenControllerconfig = new TokenController.Configuration("https://lightoidc.srsoftware.de", 10); // TODO configure or derive from hostname new TokenController(fileStore, fileStore, keyManager, fileStore, tokenControllerconfig).bindPath(API_TOKEN).on(server); new ClientController(fileStore, fileStore, fileStore).bindPath(API_CLIENT).on(server); diff --git a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java index 53e8783..0e70f5f 100644 --- a/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java +++ b/de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java @@ -1,11 +1,13 @@ /* © SRSoftware 2024 */ package de.srsoftware.oidc.backend; +import static de.srsoftware.oidc.api.Constants.APP_NAME; import static de.srsoftware.oidc.api.data.Permission.MANAGE_USERS; import static de.srsoftware.oidc.api.data.User.*; import static de.srsoftware.utils.Strings.uuid; import static java.lang.System.Logger.Level.WARNING; import static java.net.HttpURLConnection.*; +import static java.nio.charset.StandardCharsets.UTF_8; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.http.SessionToken; @@ -17,16 +19,19 @@ import jakarta.mail.internet.*; import java.io.IOException; import java.util.Map; import java.util.Optional; +import java.util.Set; import org.json.JSONObject; public class UserController extends Controller { - private final UserService users; - private final MailConfig mailConfig; + private final UserService users; + private final MailConfig mailConfig; + private final ResourceLoader resourceLoader; - public UserController(MailConfig mailConfig, SessionService sessionService, UserService userService) { + public UserController(MailConfig mailConfig, SessionService sessionService, UserService userService, ResourceLoader resourceLoader) { super(sessionService); - users = userService; - this.mailConfig = mailConfig; + users = userService; + this.mailConfig = mailConfig; + this.resourceLoader = resourceLoader; } private boolean addUser(HttpExchange ex, Session session) throws IOException { @@ -43,6 +48,9 @@ public class UserController extends Controller { switch (path) { case "/info": return userInfo(ex); + + case "/reset": + return checkResetLink(ex); } var optSession = getSession(ex); if (optSession.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED, ex); @@ -86,6 +94,11 @@ public class UserController extends Controller { return notFound(ex); } + private boolean checkResetLink(HttpExchange ex) { + // TODO + throw new RuntimeException("not implemented"); + } + private boolean list(HttpExchange ex, Session session) throws IOException { var user = session.user(); if (!user.hasPermission(MANAGE_USERS)) return sendEmptyResponse(HTTP_FORBIDDEN, ex); @@ -112,24 +125,42 @@ public class UserController extends Controller { } private boolean resetPassword(HttpExchange ex) throws IOException { - var idOrEmail = body(ex); - users.find(idOrEmail).forEach(this::senPasswordLink); + var idOrEmail = body(ex); + var url = url(ex); + Set matchingUsers = users.find(idOrEmail); + if (!matchingUsers.isEmpty()) { + resourceLoader // + .loadFile(language(ex), "reset_password.template.txt") + .map(ResourceLoader.Resource::content) + .map(bytes -> new String(bytes, UTF_8)) + .ifPresent(template -> { // + matchingUsers.forEach(user -> senPasswordLink(user, template, url)); + }); + } return sendEmptyResponse(HTTP_OK, ex); } - private void senPasswordLink(User user) { + private void senPasswordLink(User user, String template, String url) { LOG.log(WARNING, "Sending password link to {0}", user.email()); + var token = users.accessToken(user); + + var parts = template // + .replace("{service}", APP_NAME) + .replace("{displayname}", user.realName()) + .replace("{link}", String.join("?token=", url, token.id())) + .split("\n", 2); + var subj = parts[0]; + var content = parts[1]; try { var session = jakarta.mail.Session.getDefaultInstance(mailConfig.props(), mailConfig.authenticator()); Message message = new MimeMessage(session); message.setFrom(new InternetAddress(mailConfig.senderAddress())); message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(user.email())); - message.setSubject("Mail Subject"); + message.setSubject(subj); - String msg = "This is my first email using JavaMailer"; MimeBodyPart mimeBodyPart = new MimeBodyPart(); - mimeBodyPart.setContent(msg, "text/html; charset=utf-8"); + mimeBodyPart.setContent(content, "text/plain; charset=utf-8"); Multipart multipart = new MimeMultipart(); multipart.addBodyPart(mimeBodyPart); diff --git a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java index 0cd64a4..ecab28c 100644 --- a/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java +++ b/de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java @@ -37,7 +37,7 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe private final PasswordHasher passwordHasher; private Duration sessionDuration = Duration.of(10, ChronoUnit.MINUTES); private Map clients = new HashMap<>(); - private Map accessTokens = new HashMap<>(); + private Map accessTokens = new HashMap<>(); private Map authCodes = new HashMap<>(); private Authenticator auth; @@ -67,9 +67,9 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe /*** User Service Methods ***/ @Override - public String accessToken(User user) { - var token = uuid(); - accessTokens.put(token, Objects.requireNonNull(user)); + public AccessToken accessToken(User user) { + var token = new AccessToken(uuid(), Objects.requireNonNull(user), Instant.now().plus(1, ChronoUnit.HOURS)); + accessTokens.put(token.id(), token); return token; } @@ -80,8 +80,12 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe } @Override - public Optional forToken(String accessToken) { - return nullable(accessTokens.get(accessToken)); + public Optional forToken(String id) { + AccessToken token = accessTokens.get(id); + if (token == null) return empty(); + if (token.valid()) return Optional.of(token.user()); + accessTokens.remove(token.id()); + return empty(); } @Override diff --git a/de.srsoftware.oidc.web/src/main/java/de/srsoftware/oidc/web/StaticPages.java b/de.srsoftware.oidc.web/src/main/java/de/srsoftware/oidc/web/StaticPages.java index 26deaa1..15bcbe8 100644 --- a/de.srsoftware.oidc.web/src/main/java/de/srsoftware/oidc/web/StaticPages.java +++ b/de.srsoftware.oidc.web/src/main/java/de/srsoftware/oidc/web/StaticPages.java @@ -6,6 +6,7 @@ import static java.util.Optional.empty; import com.sun.net.httpserver.HttpExchange; import de.srsoftware.http.PathHandler; +import de.srsoftware.oidc.api.ResourceLoader; import java.io.FileNotFoundException; import java.io.IOException; import java.net.MalformedURLException; @@ -14,9 +15,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.util.Optional; -public class StaticPages extends PathHandler { - private static final String DEFAULT_LANGUAGE = "en"; - private static final String FAVICON = "favicon.ico"; +public class StaticPages extends PathHandler implements ResourceLoader { + private static final String FAVICON = "favicon.ico"; private final Optional base; private ClassLoader loader; @@ -25,22 +25,21 @@ public class StaticPages extends PathHandler { base = basePath; } - private record Response(String contentType, byte[] content) { - } + private static final String INDEX = "en/index.html"; @Override public boolean doGet(String relativePath, HttpExchange ex) throws IOException { - String lang = language(ex).orElse(DEFAULT_LANGUAGE); + String lang = language(ex); if (relativePath.startsWith("/")) relativePath = relativePath.substring(1); if (relativePath.isBlank()) { relativePath = ex.getRequestURI().toString().endsWith(FAVICON) ? FAVICON : INDEX; } try { - Response response = loadFile(lang, relativePath).orElseThrow(() -> new FileNotFoundException()); - ex.getResponseHeaders().add(CONTENT_TYPE, response.contentType); + Resource resource = loadFile(lang, relativePath).orElseThrow(() -> new FileNotFoundException()); + ex.getResponseHeaders().add(CONTENT_TYPE, resource.contentType()); LOG.log(DEBUG, "Loaded {0} for language {1}…success.", relativePath, lang); - return sendContent(ex, response.content); + return sendContent(ex, resource.content()); } catch (FileNotFoundException fnf) { LOG.log(WARNING, "Loaded {0} for language {1}…failed.", relativePath, lang); return notFound(ex); @@ -67,14 +66,14 @@ public class StaticPages extends PathHandler { return resource; } - private Optional loadFile(String language, String path) { + public Optional loadFile(String language, String path) { try { var resource = base.map(b -> getLocalUrl(b, language, path)).orElseGet(() -> getResource(language, path)); if (resource == null) return empty(); var connection = resource.openConnection(); var contentType = connection.getContentType(); try (var in = connection.getInputStream()) { - return Optional.of(new Response(contentType, in.readAllBytes())); + return Optional.of(new Resource(contentType, in.readAllBytes())); } } catch (IOException e) { throw new RuntimeException(e); diff --git a/de.srsoftware.oidc.web/src/main/resources/email.template/scratch.txt b/de.srsoftware.oidc.web/src/main/resources/email.template/scratch.txt new file mode 100644 index 0000000..171fc6f --- /dev/null +++ b/de.srsoftware.oidc.web/src/main/resources/email.template/scratch.txt @@ -0,0 +1,12 @@ +Password reset link for {service} +Dear {displayname}, + +Someone – probably you – requested to reset you password on {service}. + +If that was you, please open the following link in your browser: + +{link} + +If you *did not request* to reset you password, simply ignore this mail. + +Best wishes, you OIDC admin. \ No newline at end of file diff --git a/de.srsoftware.oidc.web/src/main/resources/en/authorization.html b/de.srsoftware.oidc.web/src/main/resources/en/authorization.html index 0a8bf95..370a351 100644 --- a/de.srsoftware.oidc.web/src/main/resources/en/authorization.html +++ b/de.srsoftware.oidc.web/src/main/resources/en/authorization.html @@ -22,6 +22,6 @@ - + \ No newline at end of file diff --git a/de.srsoftware.oidc.web/src/main/resources/en/reset_password.template.txt b/de.srsoftware.oidc.web/src/main/resources/en/reset_password.template.txt new file mode 100644 index 0000000..171fc6f --- /dev/null +++ b/de.srsoftware.oidc.web/src/main/resources/en/reset_password.template.txt @@ -0,0 +1,12 @@ +Password reset link for {service} +Dear {displayname}, + +Someone – probably you – requested to reset you password on {service}. + +If that was you, please open the following link in your browser: + +{link} + +If you *did not request* to reset you password, simply ignore this mail. + +Best wishes, you OIDC admin. \ No newline at end of file diff --git a/de.srsoftware.oidc.web/src/main/resources/en/todo.html b/de.srsoftware.oidc.web/src/main/resources/en/todo.html index 1f06bcb..426622e 100644 --- a/de.srsoftware.oidc.web/src/main/resources/en/todo.html +++ b/de.srsoftware.oidc.web/src/main/resources/en/todo.html @@ -16,6 +16,11 @@
  • Users: send password reset link
  • Login: send password reset link
  • Login: "remember me" option
  • +
  • at_hash in ID Token
  • +
  • drop outdated sessions
  • +
  • invalidate tokens
  • +
  • implement token refresh
  • +
  • handle https correctly in PathHandler.hostname