implemented password reset link and sending via mail
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
@@ -18,6 +18,7 @@ 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 DEFAULT_LANGUAGE = "en";
|
||||||
public static final String DELETE = "DELETE";
|
public static final String DELETE = "DELETE";
|
||||||
private static final String FORWARDED_HOST = "x-forwarded-host";
|
private static final String FORWARDED_HOST = "x-forwarded-host";
|
||||||
public static final String GET = "GET";
|
public static final String GET = "GET";
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -113,23 +126,41 @@ 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);
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,8 +15,7 @@ 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);
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
@@ -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.
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user