implemented otp login
Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
This commit is contained in:
@@ -1,6 +1,11 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.core.api;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
public interface Translator {
|
||||
public String translate(String language, String text);
|
||||
public default String translate(String language, String text){
|
||||
return translate(language, text, Map.of());
|
||||
}
|
||||
public String translate(String language, String text, Map<String,String> fills);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
/* © SRSoftware 2025 */
|
||||
package de.srsoftware.umbrella.core.model;
|
||||
|
||||
import de.srsoftware.umbrella.core.UmbrellaException;
|
||||
|
||||
import static de.srsoftware.tools.Optionals.allSet;
|
||||
import static java.net.HttpURLConnection.HTTP_BAD_REQUEST;
|
||||
import static java.text.MessageFormat.format;
|
||||
|
||||
public class EmailAddress {
|
||||
private final String email;
|
||||
|
||||
public EmailAddress(String addr){
|
||||
public EmailAddress(String addr) throws UmbrellaException {
|
||||
var parts = addr.split("@");
|
||||
if (parts.length != 2 || !allSet(parts[0],parts[1])) throw new IllegalArgumentException(format("{0} is not a valid email address!",addr));
|
||||
if (parts.length != 2 || !allSet(parts[0],parts[1])) throw new UmbrellaException(HTTP_BAD_REQUEST,"\"{0}\" is not a valid email address",addr);
|
||||
email = addr;
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ public class UmbrellaUser implements Mappable {
|
||||
map.put(ID,id);
|
||||
map.put(LOGIN, name()); // this is still used by old umbrella modules
|
||||
map.put(NAME, name());
|
||||
map.put(EMAIL,email().toString());
|
||||
map.put(EMAIL,email() instanceof EmailAddress ea ? ea.toString() : null);
|
||||
map.put(THEME,theme);
|
||||
map.put(LANGUAGE,lang);
|
||||
return map;
|
||||
|
||||
@@ -1,24 +1,45 @@
|
||||
<script>
|
||||
import { onMount } from 'svelte';
|
||||
import { t } from '../../translations.svelte.js';
|
||||
import { useTinyRouter } from 'svelte-tiny-router';
|
||||
|
||||
let mail = "";
|
||||
let caption = t('user.send_mail');
|
||||
let error = null;
|
||||
const router = useTinyRouter();
|
||||
|
||||
async function submit(ev){
|
||||
ev.preventDefault();
|
||||
const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/user/reset_pw`;
|
||||
caption = t('user.data_sent');
|
||||
const resp = await fetch(url,{
|
||||
method : 'POST',
|
||||
body : mail
|
||||
});
|
||||
if (resp.ok) {
|
||||
caption = t('user.data_sent');
|
||||
} else {
|
||||
caption = await resp.text();
|
||||
}
|
||||
}
|
||||
|
||||
async function checkToken(){
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const token = urlParams.get('token');
|
||||
console.log({token:token,params:urlParams});
|
||||
if (token) {
|
||||
const url = `${location.protocol}//${location.host.replace('5173','8080')}/api/user/validate/${token}`;
|
||||
const resp = await fetch(url,{
|
||||
credentials: 'include'
|
||||
});
|
||||
if (resp.ok){
|
||||
router.navigate('/user')
|
||||
} else {
|
||||
error = await resp.text();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onMount(checkToken);
|
||||
|
||||
|
||||
</script>
|
||||
@@ -44,5 +65,8 @@
|
||||
{t('user.enter_email')}
|
||||
</label>
|
||||
<button type="submit">{caption}</button>
|
||||
{#if error}
|
||||
<div class="error">{error}</div>
|
||||
{/if}
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -24,6 +24,7 @@ import jakarta.mail.internet.MimeMultipart;
|
||||
import jakarta.mail.util.ByteArrayDataSource;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class MessageSystem implements PostBox {
|
||||
@@ -113,9 +114,9 @@ public class MessageSystem implements PostBox {
|
||||
var date = new Date();
|
||||
|
||||
for (var receiver : dueRecipients){
|
||||
Function<String,String> translateFunction = receiver instanceof UmbrellaUser uu ? text -> translator.translate(uu.language(),text) : text -> text;
|
||||
// combine messages for user
|
||||
var combined = new CombinedMessage(translateFunction);
|
||||
BiFunction<String,Map<String,String>,String> translateFunction = (text,fills) -> translator.translate(receiver.language(),text,fills);
|
||||
|
||||
var combined = new CombinedMessage("Collected messages",translateFunction);
|
||||
var envelopes = queue.stream().filter(env -> env.isFor(receiver)).toList();
|
||||
for (var envelope : envelopes) combined.merge(envelope.message());
|
||||
|
||||
|
||||
@@ -6,43 +6,46 @@ import static java.lang.System.Logger.Level.TRACE;
|
||||
import static java.text.MessageFormat.format;
|
||||
|
||||
import de.srsoftware.umbrella.core.model.UmbrellaUser;
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Set;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.function.BiFunction;
|
||||
import java.util.function.Function;
|
||||
|
||||
public class CombinedMessage {
|
||||
private static final System.Logger LOG = System.getLogger(CombinedMessage.class.getSimpleName());
|
||||
|
||||
private final Set<Attachment> attachments = new HashSet<>();
|
||||
private final StringBuilder body = new StringBuilder();
|
||||
private final StringBuilder combinedBody = new StringBuilder();
|
||||
private String combinedSubject = null;
|
||||
private final List<Message> mergedMessages = new ArrayList<>();
|
||||
private final Function<String, String> translate;
|
||||
private final String subjectForCombinedMessage;
|
||||
private final BiFunction<String,Map<String,String>,String> translate;
|
||||
private UmbrellaUser sender = null;
|
||||
private String subject = null;
|
||||
|
||||
public CombinedMessage(Function<String,String> translate){
|
||||
public CombinedMessage(String subjectForCombinedMessage, BiFunction<String, Map<String,String>,String> translateFunction){
|
||||
LOG.log(DEBUG,"Creating combined message…");
|
||||
this.translate = translate;
|
||||
this.subjectForCombinedMessage = subjectForCombinedMessage;
|
||||
translate = translateFunction;
|
||||
}
|
||||
|
||||
public void merge(Message message) {
|
||||
LOG.log(TRACE,"Merging {0} into combined message…",message);
|
||||
var body = translate.apply(message.body(),message.fills());
|
||||
var subject = translate.apply(message.subject(),message.fills());
|
||||
switch (mergedMessages.size()){
|
||||
case 0:
|
||||
body.append(message.body());
|
||||
combinedBody.append(body);
|
||||
sender = message.sender();
|
||||
subject = message.subject();
|
||||
combinedSubject = subject;
|
||||
break;
|
||||
case 1:
|
||||
body.insert(0,format("# {0}:\n# {1}:\n\n",sender,subject)); // insert sender and subject of first message right before the body of the first message
|
||||
subject = translate.apply("Collected messages");
|
||||
combinedBody.insert(0,format("# {0}:\n# {1}:\n\n",sender,subject)); // insert sender and subject of first message right before the body of the first message
|
||||
combinedSubject = subjectForCombinedMessage;
|
||||
// no break here, we need to append the subject and content
|
||||
default:
|
||||
body.append("\n\n# ").append(message.sender()).append(":\n");
|
||||
body.append("# ").append(message.subject()).append(":\n\n");
|
||||
body.append(message.body());
|
||||
combinedBody.append("\n\n# ").append(message.sender()).append(":\n");
|
||||
combinedBody.append("# ").append(subject).append(":\n\n");
|
||||
combinedBody.append(body);
|
||||
}
|
||||
if (message.attachments() != null) attachments.addAll(message.attachments());
|
||||
mergedMessages.add(message);
|
||||
@@ -53,7 +56,7 @@ public class CombinedMessage {
|
||||
}
|
||||
|
||||
public String body() {
|
||||
return body.toString();
|
||||
return combinedBody.toString();
|
||||
}
|
||||
|
||||
public UmbrellaUser sender() {
|
||||
@@ -61,7 +64,7 @@ public class CombinedMessage {
|
||||
}
|
||||
|
||||
public String subject() {
|
||||
return subject;
|
||||
return combinedSubject;
|
||||
}
|
||||
|
||||
public List<Message> messages() {
|
||||
|
||||
@@ -12,6 +12,7 @@ import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.net.URL;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import org.json.JSONObject;
|
||||
|
||||
public class Translations extends PathHandler implements Translator {
|
||||
@@ -34,7 +35,7 @@ public class Translations extends PathHandler implements Translator {
|
||||
}
|
||||
|
||||
private JSONObject loadTranslations(String lang) throws IOException {
|
||||
LOG.log(WARNING,"loadTranslations({0} not implemented!",lang);
|
||||
LOG.log(WARNING,"loadTranslations({0}) not implemented!",lang);
|
||||
var filename = lang + ".json";
|
||||
URL url = getClass().getClassLoader().getResource(filename);
|
||||
if (url == null) return new JSONObject();
|
||||
@@ -50,10 +51,19 @@ public class Translations extends PathHandler implements Translator {
|
||||
}
|
||||
|
||||
@Override
|
||||
public String translate(String language, String text) {
|
||||
public String translate(String language, String text, Map<String,String> fills) {
|
||||
try {
|
||||
var translations = getTranslations(language);
|
||||
return translations.has(text) ? translations.getString(text) : text;
|
||||
var keys = text.split("\\.");
|
||||
Object current = translations;
|
||||
for (var key : keys){
|
||||
if (current instanceof JSONObject json && json.has(key)) {
|
||||
current = json.get(key);
|
||||
} else current = null;
|
||||
}
|
||||
var translated = current instanceof String translation ? translation : text;
|
||||
for (var entry : fills.entrySet()) translated = translated.replaceAll("\\{"+entry.getKey()+"}",entry.getValue());
|
||||
return translated;
|
||||
} catch (IOException e) {
|
||||
return text;
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"email": "E-Mail",
|
||||
"failed": "fehlgeschlagen",
|
||||
"foreign_id": "externe Kennung",
|
||||
"go_to_url_to_reset_password": "Um ein neues Passwort zu erhalten, öffnen Sie bitte den folgenden Link: {url}",
|
||||
"id": "Id",
|
||||
"impersonate": "zu Nutzer wechseln",
|
||||
"IMPERSONATE": "NUTZER WECHSELN",
|
||||
@@ -88,6 +89,7 @@
|
||||
"update": "aktualisieren",
|
||||
"UPDATE_USERS" : "Nutzer aktualisieren",
|
||||
"user_module" : "Umbrella User-Verwaltung",
|
||||
"your_password_reset_token" : "Ihr Token zum Erstellen eines neuen Passworts",
|
||||
"your_profile": "dein Profil"
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,6 @@ public class Paths {
|
||||
public static final String OPENID_LOGIN = "openid_login";
|
||||
public static final String RESET_PW = "reset_pw";
|
||||
public static final String SESSION = "session";
|
||||
public static final String VALIDATE_TOKEN = "validateToken";
|
||||
public static final String VALIDATE_TOKEN = "validate";
|
||||
public static final String WHOAMI = "whoami";
|
||||
}
|
||||
|
||||
@@ -158,6 +158,7 @@ public class UserModule extends BaseHandler {
|
||||
case LIST: return getUserList(ex, user);
|
||||
case LOGOUT: return logout(ex, sessionToken);
|
||||
case OIDC: return getOIDC(ex,user,path);
|
||||
case VALIDATE_TOKEN: return validateToken(ex,path.pop());
|
||||
case WHOAMI: return getUser(ex, user);
|
||||
|
||||
};
|
||||
@@ -477,12 +478,13 @@ public class UserModule extends BaseHandler {
|
||||
tokenMap.put(email,token);
|
||||
var subject = "user.your_password_reset_token";
|
||||
var content = "user.go_to_url_to_reset_password";
|
||||
var fills = Map.of("token",token);
|
||||
var url = url(ex).replace("/api/user/reset_pw","/user/reset/pw")+"?token="+token;
|
||||
var fills = Map.of("url",url);
|
||||
var message = new Message(user,subject,content,fills,null);
|
||||
var envelope = new Envelope(message,user);
|
||||
messages.send(envelope);
|
||||
} catch (UmbrellaException e){
|
||||
|
||||
return send(ex,e);
|
||||
}
|
||||
return sendEmptyResponse(HTTP_OK,ex);
|
||||
}
|
||||
@@ -540,6 +542,21 @@ public class UserModule extends BaseHandler {
|
||||
return sendContent(ex,HTTP_OK,saved);
|
||||
}
|
||||
|
||||
private boolean validateToken(HttpExchange ex, String token) throws IOException {
|
||||
if (token == null) return sendContent(ex,HTTP_UNPROCESSABLE,"No token provided!");
|
||||
var email = tokenMap.get(token);
|
||||
tokenMap.remove(token);
|
||||
if (email == null) return sendContent(ex,HTTP_UNAUTHORIZED,"Unknown token!");
|
||||
try {
|
||||
var user = users.load(new EmailAddress(email));
|
||||
users.getSession(user).cookie().addTo(ex);
|
||||
return sendContent(ex,user);
|
||||
} catch (UmbrellaException e) {
|
||||
return send(ex,e);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private String verifyAndGetUserId(String jwt, State state) throws UmbrellaException {
|
||||
var jwksEndpoint = state.config.getString(JWKS_ENDPOINT);
|
||||
var audience = state.loginService.clientId();
|
||||
|
||||
@@ -511,7 +511,7 @@ CREATE TABLE IF NOT EXISTS {0} (
|
||||
);
|
||||
}
|
||||
|
||||
private DbUser toUser(ResultSet rs) throws SQLException {
|
||||
private DbUser toUser(ResultSet rs) throws SQLException, UmbrellaException {
|
||||
long id = rs.getLong(ID);
|
||||
Set<DbUser.PERMISSION> perms = id == 1 ? ADMIN_PERMISSIONS : Set.of();
|
||||
return new DbUser(
|
||||
|
||||
Reference in New Issue
Block a user