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. 16
      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. 43
      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. 19
      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

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

@ -18,6 +18,7 @@ import org.json.JSONObject; @@ -18,6 +18,7 @@ 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 DEFAULT_LANGUAGE = "en";
public static final String DELETE = "DELETE";
private static final String FORWARDED_HOST = "x-forwarded-host";
public static final String GET = "GET";
@ -35,9 +36,9 @@ public abstract class PathHandler implements HttpHandler { @@ -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 { @@ -132,8 +133,11 @@ public abstract class PathHandler implements HttpHandler {
return new JSONObject(body(ex));
}
public static Optional<String> 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 { @@ -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();
}
}

10
de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/ResourceLoader.java

@ -0,0 +1,10 @@ @@ -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 @@ @@ -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 { @@ -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);
/**

10
de.srsoftware.oidc.api/src/main/java/de/srsoftware/oidc/api/data/AccessToken.java

@ -0,0 +1,10 @@ @@ -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 { @@ -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);

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

@ -1,11 +1,13 @@ @@ -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.*; @@ -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 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;
this.resourceLoader = resourceLoader;
}
private boolean addUser(HttpExchange ex, Session session) throws IOException {
@ -43,6 +48,9 @@ public class UserController extends Controller { @@ -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 { @@ -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);
@ -113,23 +126,41 @@ public class UserController extends Controller { @@ -113,23 +126,41 @@ public class UserController extends Controller {
private boolean resetPassword(HttpExchange ex) throws IOException {
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);
}
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);

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 @@ -37,7 +37,7 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe
private final PasswordHasher<String> passwordHasher;
private Duration sessionDuration = Duration.of(10, ChronoUnit.MINUTES);
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 Authenticator auth;
@ -67,9 +67,9 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe @@ -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 @@ -80,8 +80,12 @@ public class FileStore implements AuthorizationService, ClientService, SessionSe
}
@Override
public Optional<User> forToken(String accessToken) {
return nullable(accessTokens.get(accessToken));
public Optional<User> 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

19
de.srsoftware.oidc.web/src/main/java/de/srsoftware/oidc/web/StaticPages.java

@ -6,6 +6,7 @@ import static java.util.Optional.empty; @@ -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,8 +15,7 @@ import java.nio.file.Files; @@ -14,8 +15,7 @@ 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";
public class StaticPages extends PathHandler implements ResourceLoader {
private static final String FAVICON = "favicon.ico";
private final Optional<Path> base;
private ClassLoader loader;
@ -25,22 +25,21 @@ public class StaticPages extends PathHandler { @@ -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 { @@ -67,14 +66,14 @@ public class StaticPages extends PathHandler {
return resource;
}
private Optional<Response> loadFile(String language, String path) {
public Optional<Resource> 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);

12
de.srsoftware.oidc.web/src/main/resources/email.template/scratch.txt

@ -0,0 +1,12 @@ @@ -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 @@ @@ -22,6 +22,6 @@
<button type="button" onclick="denyAutorization()">No</button>
</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>
</html>

12
de.srsoftware.oidc.web/src/main/resources/en/reset_password.template.txt

@ -0,0 +1,12 @@ @@ -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 @@ @@ -16,6 +16,11 @@
<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: "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>
</div>
</body>

Loading…
Cancel
Save