Browse Source

implemented password reset link and sending via mail

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
sqlite
Stephan Richter 4 months ago
parent
commit
95d47e3d63
  1. 34
      de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java
  2. 10
      de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/ResourceLoader.java
  3. 3
      de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java
  4. 10
      de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/AccessToken.java
  5. 4
      de.srsoftware.oidc.app/src/main/java/de/srsoftware/oidc/app/Application.java
  6. 53
      de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java
  7. 16
      de.srsoftware.oidc.datastore.file/src/main/java/de/srsoftware/oidc/datastore/file/FileStore.java
  8. 21
      de.srsoftware.oidc.web/src/main/java/de/srsoftware/oidc/web/StaticPages.java
  9. 12
      de.srsoftware.oidc.web/src/main/resources/email.template/scratch.txt
  10. 2
      de.srsoftware.oidc.web/src/main/resources/en/authorization.html
  11. 12
      de.srsoftware.oidc.web/src/main/resources/en/reset_password.template.txt
  12. 5
      de.srsoftware.oidc.web/src/main/resources/en/todo.html

34
de.srsoftware.http/src/main/java/de/srsoftware/http/PathHandler.java

@ -16,15 +16,16 @@ import java.util.stream.Stream;
import org.json.JSONObject; import org.json.JSONObject;
public abstract class PathHandler implements HttpHandler { public abstract class PathHandler implements HttpHandler {
public static final String AUTHORIZATION = "Authorization"; public static final String AUTHORIZATION = "Authorization";
public static final String CONTENT_TYPE = "Content-Type"; public static final String CONTENT_TYPE = "Content-Type";
public static final String DELETE = "DELETE"; public static final String DEFAULT_LANGUAGE = "en";
private static final String FORWARDED_HOST = "x-forwarded-host"; public static final String DELETE = "DELETE";
public static final String GET = "GET"; private static final String FORWARDED_HOST = "x-forwarded-host";
public static final String HOST = "host"; public static final String GET = "GET";
public static final String JSON = "application/json"; public static final String HOST = "host";
public static System.Logger LOG = System.getLogger(PathHandler.class.getSimpleName()); public static final String JSON = "application/json";
public static final String POST = "POST"; public static System.Logger LOG = System.getLogger(PathHandler.class.getSimpleName());
public static final String POST = "POST";
private String[] paths; private String[] paths;
@ -35,9 +36,9 @@ public abstract class PathHandler implements HttpHandler {
Bond(String[] paths) { Bond(String[] paths) {
PathHandler.this.paths = paths; PathHandler.this.paths = paths;
} }
public HttpServer on(HttpServer server) { public PathHandler on(HttpServer server) {
for (var path : paths) server.createContext(path, PathHandler.this); 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)); return new JSONObject(body(ex));
} }
public static Optional<String> language(HttpExchange ex) { public static String language(HttpExchange ex) {
return getHeader(ex, "Accept-Language").map(s -> Arrays.stream(s.split(","))).flatMap(Stream::findFirst); 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 { 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 { public static boolean sendContent(HttpExchange ex, Object o) throws IOException {
return sendContent(ex, HTTP_OK, o); return sendContent(ex, HTTP_OK, o);
} }
public static String url(HttpExchange ex) {
return hostname(ex) + ex.getRequestURI();
}
} }

10
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<Resource> loadFile(String lang, String path);
}

3
de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/UserService.java

@ -1,6 +1,7 @@
/* © SRSoftware 2024 */ /* © SRSoftware 2024 */
package de.srsoftware.oidc.api; package de.srsoftware.oidc.api;
import de.srsoftware.oidc.api.data.AccessToken;
import de.srsoftware.oidc.api.data.User; import de.srsoftware.oidc.api.data.User;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
@ -12,7 +13,7 @@ public interface UserService {
* @param user * @param user
* @return * @return
*/ */
public String accessToken(User user); public AccessToken accessToken(User user);
public UserService delete(User user); public UserService delete(User user);
/** /**

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

4
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); KeyManager keyManager = new RotatingKeyManager(keyStore);
FileStore fileStore = new FileStore(storageFile, passwordHasher).init(firstUser); FileStore fileStore = new FileStore(storageFile, passwordHasher).init(firstUser);
HttpServer server = HttpServer.create(new InetSocketAddress(8080), 0); 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 Forward(INDEX).bindPath(ROOT).on(server);
new WellKnownController().bindPath(WELL_KNOWN).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 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 TokenController(fileStore, fileStore, keyManager, fileStore, tokenControllerconfig).bindPath(API_TOKEN).on(server);
new ClientController(fileStore, fileStore, fileStore).bindPath(API_CLIENT).on(server); new ClientController(fileStore, fileStore, fileStore).bindPath(API_CLIENT).on(server);

53
de.srsoftware.oidc.backend/src/main/java/de/srsoftware/oidc/backend/UserController.java

@ -1,11 +1,13 @@
/* © SRSoftware 2024 */ /* © SRSoftware 2024 */
package de.srsoftware.oidc.backend; 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.Permission.MANAGE_USERS;
import static de.srsoftware.oidc.api.data.User.*; import static de.srsoftware.oidc.api.data.User.*;
import static de.srsoftware.utils.Strings.uuid; import static de.srsoftware.utils.Strings.uuid;
import static java.lang.System.Logger.Level.WARNING; import static java.lang.System.Logger.Level.WARNING;
import static java.net.HttpURLConnection.*; import static java.net.HttpURLConnection.*;
import static java.nio.charset.StandardCharsets.UTF_8;
import com.sun.net.httpserver.HttpExchange; import com.sun.net.httpserver.HttpExchange;
import de.srsoftware.http.SessionToken; import de.srsoftware.http.SessionToken;
@ -17,16 +19,19 @@ import jakarta.mail.internet.*;
import java.io.IOException; import java.io.IOException;
import java.util.Map; import java.util.Map;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import org.json.JSONObject; import org.json.JSONObject;
public class UserController extends Controller { public class UserController extends Controller {
private final UserService users; private final UserService users;
private final MailConfig mailConfig; 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); super(sessionService);
users = userService; users = userService;
this.mailConfig = mailConfig; this.mailConfig = mailConfig;
this.resourceLoader = resourceLoader;
} }
private boolean addUser(HttpExchange ex, Session session) throws IOException { private boolean addUser(HttpExchange ex, Session session) throws IOException {
@ -43,6 +48,9 @@ public class UserController extends Controller {
switch (path) { switch (path) {
case "/info": case "/info":
return userInfo(ex); return userInfo(ex);
case "/reset":
return checkResetLink(ex);
} }
var optSession = getSession(ex); var optSession = getSession(ex);
if (optSession.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED, ex); if (optSession.isEmpty()) return sendEmptyResponse(HTTP_UNAUTHORIZED, ex);
@ -86,6 +94,11 @@ public class UserController extends Controller {
return notFound(ex); return notFound(ex);
} }
private boolean checkResetLink(HttpExchange ex) {
// TODO
throw new RuntimeException("not implemented");
}
private boolean list(HttpExchange ex, Session session) throws IOException { private boolean list(HttpExchange ex, Session session) throws IOException {
var user = session.user(); var user = session.user();
if (!user.hasPermission(MANAGE_USERS)) return sendEmptyResponse(HTTP_FORBIDDEN, ex); 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 { private boolean resetPassword(HttpExchange ex) throws IOException {
var idOrEmail = body(ex); var idOrEmail = body(ex);
users.find(idOrEmail).forEach(this::senPasswordLink); var url = url(ex);
Set<User> 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); 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()); 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 { try {
var session = jakarta.mail.Session.getDefaultInstance(mailConfig.props(), mailConfig.authenticator()); var session = jakarta.mail.Session.getDefaultInstance(mailConfig.props(), mailConfig.authenticator());
Message message = new MimeMessage(session); Message message = new MimeMessage(session);
message.setFrom(new InternetAddress(mailConfig.senderAddress())); message.setFrom(new InternetAddress(mailConfig.senderAddress()));
message.setRecipients(Message.RecipientType.TO, InternetAddress.parse(user.email())); 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 mimeBodyPart = new MimeBodyPart();
mimeBodyPart.setContent(msg, "text/html; charset=utf-8"); mimeBodyPart.setContent(content, "text/plain; charset=utf-8");
Multipart multipart = new MimeMultipart(); Multipart multipart = new MimeMultipart();
multipart.addBodyPart(mimeBodyPart); multipart.addBodyPart(mimeBodyPart);

16
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<String> passwordHasher; private final PasswordHasher<String> passwordHasher;
private Duration sessionDuration = Duration.of(10, ChronoUnit.MINUTES); private Duration sessionDuration = Duration.of(10, ChronoUnit.MINUTES);
private Map<String, Client> clients = new HashMap<>(); private Map<String, Client> clients = new HashMap<>();
private Map<String, User> accessTokens = new HashMap<>(); private Map<String, AccessToken> accessTokens = new HashMap<>();
private Map<String, Authorization> authCodes = new HashMap<>(); private Map<String, Authorization> authCodes = new HashMap<>();
private Authenticator auth; private Authenticator auth;
@ -67,9 +67,9 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe
/*** User Service Methods ***/ /*** User Service Methods ***/
@Override @Override
public String accessToken(User user) { public AccessToken accessToken(User user) {
var token = uuid(); var token = new AccessToken(uuid(), Objects.requireNonNull(user), Instant.now().plus(1, ChronoUnit.HOURS));
accessTokens.put(token, Objects.requireNonNull(user)); accessTokens.put(token.id(), token);
return token; return token;
} }
@ -80,8 +80,12 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe
} }
@Override @Override
public Optional<User> forToken(String accessToken) { public Optional<User> forToken(String id) {
return nullable(accessTokens.get(accessToken)); 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 @Override

21
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 com.sun.net.httpserver.HttpExchange;
import de.srsoftware.http.PathHandler; import de.srsoftware.http.PathHandler;
import de.srsoftware.oidc.api.ResourceLoader;
import java.io.FileNotFoundException; import java.io.FileNotFoundException;
import java.io.IOException; import java.io.IOException;
import java.net.MalformedURLException; import java.net.MalformedURLException;
@ -14,9 +15,8 @@ import java.nio.file.Files;
import java.nio.file.Path; import java.nio.file.Path;
import java.util.Optional; import java.util.Optional;
public class StaticPages extends PathHandler { public class StaticPages extends PathHandler implements ResourceLoader {
private static final String DEFAULT_LANGUAGE = "en"; private static final String FAVICON = "favicon.ico";
private static final String FAVICON = "favicon.ico";
private final Optional<Path> base; private final Optional<Path> base;
private ClassLoader loader; private ClassLoader loader;
@ -25,22 +25,21 @@ public class StaticPages extends PathHandler {
base = basePath; base = basePath;
} }
private record Response(String contentType, byte[] content) {
}
private static final String INDEX = "en/index.html"; private static final String INDEX = "en/index.html";
@Override @Override
public boolean doGet(String relativePath, HttpExchange ex) throws IOException { 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.startsWith("/")) relativePath = relativePath.substring(1);
if (relativePath.isBlank()) { if (relativePath.isBlank()) {
relativePath = ex.getRequestURI().toString().endsWith(FAVICON) ? FAVICON : INDEX; relativePath = ex.getRequestURI().toString().endsWith(FAVICON) ? FAVICON : INDEX;
} }
try { try {
Response response = loadFile(lang, relativePath).orElseThrow(() -> new FileNotFoundException()); Resource resource = loadFile(lang, relativePath).orElseThrow(() -> new FileNotFoundException());
ex.getResponseHeaders().add(CONTENT_TYPE, response.contentType); ex.getResponseHeaders().add(CONTENT_TYPE, resource.contentType());
LOG.log(DEBUG, "Loaded {0} for language {1}…success.", relativePath, lang); LOG.log(DEBUG, "Loaded {0} for language {1}…success.", relativePath, lang);
return sendContent(ex, response.content); return sendContent(ex, resource.content());
} catch (FileNotFoundException fnf) { } catch (FileNotFoundException fnf) {
LOG.log(WARNING, "Loaded {0} for language {1}…failed.", relativePath, lang); LOG.log(WARNING, "Loaded {0} for language {1}…failed.", relativePath, lang);
return notFound(ex); return notFound(ex);
@ -67,14 +66,14 @@ public class StaticPages extends PathHandler {
return resource; return resource;
} }
private Optional<Response> loadFile(String language, String path) { public Optional<Resource> loadFile(String language, String path) {
try { try {
var resource = base.map(b -> getLocalUrl(b, language, path)).orElseGet(() -> getResource(language, path)); var resource = base.map(b -> getLocalUrl(b, language, path)).orElseGet(() -> getResource(language, path));
if (resource == null) return empty(); if (resource == null) return empty();
var connection = resource.openConnection(); var connection = resource.openConnection();
var contentType = connection.getContentType(); var contentType = connection.getContentType();
try (var in = connection.getInputStream()) { try (var in = connection.getInputStream()) {
return Optional.of(new Response(contentType, in.readAllBytes())); return Optional.of(new Resource(contentType, in.readAllBytes()));
} }
} catch (IOException e) { } catch (IOException e) {
throw new RuntimeException(e); throw new RuntimeException(e);

12
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.

2
de.srsoftware.oidc.web/src/main/resources/en/authorization.html

@ -22,6 +22,6 @@
<button type="button" onclick="denyAutorization()">No</button> <button type="button" onclick="denyAutorization()">No</button>
</div> </div>
<div id="error" class="error" style="display: none"></div> <div id="error" class="error" style="display: none"></div>
<div id="missing_scopes" class="error" style="display: none">Authorization response contained neither list of <em>unauthorized scopes</em> nor list of <em>authorized scopes</em>! This is a server problem.</div> <div id="missing_scopes" class="error" style="display: none">Authorization resource contained neither list of <em>unauthorized scopes</em> nor list of <em>authorized scopes</em>! This is a server problem.</div>
</body> </body>
</html> </html>

12
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.

5
de.srsoftware.oidc.web/src/main/resources/en/todo.html

@ -16,6 +16,11 @@
<li><a href="users.html">Users: send password reset link</a></li> <li><a href="users.html">Users: send password reset link</a></li>
<li><a href="login.html">Login: send password reset link</a></li> <li><a href="login.html">Login: send password reset link</a></li>
<li><a href="login.html">Login: "remember me" option</a></li> <li><a href="login.html">Login: "remember me" option</a></li>
<li>at_hash in ID Token</li>
<li>drop outdated sessions</li>
<li>invalidate tokens</li>
<li>implement token refresh</li>
<li>handle https correctly in PathHandler.hostname</li>
</ul> </ul>
</div> </div>
</body> </body>

Loading…
Cancel
Save