diff --git a/src/main/java/de/srsoftware/widerhall/Constants.java b/src/main/java/de/srsoftware/widerhall/Constants.java index cd57c4a..3f54df1 100644 --- a/src/main/java/de/srsoftware/widerhall/Constants.java +++ b/src/main/java/de/srsoftware/widerhall/Constants.java @@ -21,8 +21,10 @@ public class Constants { public static final String MESSAGE_ID = "message_id"; public static final String MODERATOR = "moderator"; public static final String MONTH = "month"; + public static final String NEW_PASSWORD_FORM = "new_password_form"; public static final String NOTES = "notes"; public static final String PASSWORD = "password"; + public static final String PASSWORD_REPEAT = "password-repeat"; public static final String PERMISSIONS = "permissions"; public static final Object PORT = "port"; public static final String PREFIX = "prefix"; diff --git a/src/main/java/de/srsoftware/widerhall/Util.java b/src/main/java/de/srsoftware/widerhall/Util.java index c49ce75..edff6fa 100644 --- a/src/main/java/de/srsoftware/widerhall/Util.java +++ b/src/main/java/de/srsoftware/widerhall/Util.java @@ -4,12 +4,9 @@ import de.srsoftware.tools.translations.Translation; import de.srsoftware.widerhall.data.MailingList; import de.srsoftware.widerhall.data.User; -import javax.mail.Message; import javax.mail.MessagingException; import javax.mail.Multipart; import javax.mail.Part; -import javax.mail.internet.AddressException; -import javax.mail.internet.InternetAddress; import javax.servlet.http.HttpServletRequest; import java.io.IOException; import java.net.URLEncoder; @@ -19,6 +16,7 @@ import java.security.NoSuchAlgorithmException; import java.sql.ResultSet; import java.sql.SQLException; import java.util.Map; +import java.util.Random; import java.util.stream.Collectors; import static de.srsoftware.widerhall.Constants.*; @@ -28,6 +26,14 @@ public class Util { private static final MessageDigest SHA256 = getSha256(); private static final String EMAIL_PATTERN = "^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^-]+(?:\\.[a-zA-Z0-9_!#$%&'*+/=?`{|}~^-]+)*@[a-zA-Z0-9-]+(?:\\.[a-zA-Z0-9-]+)*$"; + public static char boundedChar(int i) { + i = (i<0 ? -i : i) % 62; + i += '0'; + if (i>57) i+=7; + if (i>90) i+=6; + return (char) i; + } + public static String dropEmail(String tx) { return tx.replaceAll( "[.\\-\\w]+@[.\\-\\w]+", "[email_removed]"); } @@ -119,6 +125,18 @@ public class Util { return email.matches(EMAIL_PATTERN); } + public static String randomString(int length) { + Random rand = new Random(); + StringBuilder sb = new StringBuilder(); + for (int i=0; i listActive() { + try { + var list = new ArrayList(); + var rs = Database.open().select(TABLE_NAME).where(STATE+" % 2",1).compile().exec(); + while (rs.next()) list.add(MailingList.from(rs)); + return list; + } catch (SQLException e){ + LOG.debug("Failed to load active MailingLists"); + } + return List.of(); + } + /** * Load a ML object by it's identifying email address. * This is a cached method: if the ML has been loaded before, the already-loaded object will be returned. @@ -308,6 +320,7 @@ public class MailingList implements MessageHandler, ProblemListener { return ml; } + /** * Load a ML object by it's identifying email address. * This is a cached method: if the ML has been loaded before, the already-loaded object will be returned. @@ -679,6 +692,10 @@ public class MailingList implements MessageHandler, ProblemListener { } } + public void sendPasswordReset(String email, String subject,String text) throws MessagingException, UnsupportedEncodingException { + smtp.send(email(),name(),email,subject,text); + } + protected SmtpClient smtp(){ return smtp; } diff --git a/src/main/java/de/srsoftware/widerhall/data/User.java b/src/main/java/de/srsoftware/widerhall/data/User.java index ce1bbaa..ba4efe6 100644 --- a/src/main/java/de/srsoftware/widerhall/data/User.java +++ b/src/main/java/de/srsoftware/widerhall/data/User.java @@ -6,6 +6,7 @@ import java.security.InvalidKeyException; import java.sql.ResultSet; import java.sql.SQLException; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.*; import static de.srsoftware.widerhall.Constants.*; @@ -20,10 +21,11 @@ public class User { public static final int PERMISSION_ADMIN = 1; public static final int PERMISSION_CREATE_LISTS = 2; public static final String HASHED_PASS = "hashedPassword"; + public static final String RESET_TOKEN = "resetToken"; public static final String SALT = "salt"; private static final HashMap users = new HashMap<>(); - private String email, salt, hashedPass, name; + private String email, salt, hashedPass, name, token; private int permissions; /** @@ -34,11 +36,12 @@ public class User { * @param hashedPass * @param permissions */ - public User(String email, String name, String salt, String hashedPass, int permissions) { + public User(String email, String name, String salt, String hashedPass, String token, int permissions) { this.email = email.toLowerCase(); this.name = name; this.salt = salt; this.hashedPass = hashedPass; + this.token = token; this.permissions = permissions; } @@ -81,6 +84,21 @@ public class User { .run(); } + public static void addTokenColumn() throws SQLException { + String sql = "ALTER TABLE %s ADD COLUMN %s %s;".formatted(TABLE_NAME,RESET_TOKEN,VARCHAR); + Database.open().query(sql).compile().run(); + } + + public static User byToken(String token) throws SQLException { + if (token == null || token.isBlank()) return null; + var rs = Database.open().select(TABLE_NAME).where(RESET_TOKEN,token).compile().exec(); + try { + if (rs.next()) return User.from(rs); + return null; + } finally { + rs.close(); + } + } /** * Create a new user object by hashing it's password and storing user data, salt and hashed password to the db. @@ -96,10 +114,10 @@ public class User { String salt = null; String hashedPass = null; if (password != null) { - salt = Util.sha256(email + name + LocalDate.now()); + salt = Util.sha256(email + LocalDateTime.now() + name); hashedPass = Util.sha256(password + salt); } - return new User(email,name,salt,hashedPass,0).save(); + return new User(email,name,salt,hashedPass,null,0).save(); } /** @@ -115,7 +133,6 @@ public class User { .append(PERMISSIONS).append(" ").append(INT).append(", ") .append(SALT).append(" ").append(VARCHAR).append(", ") .append(HASHED_PASS).append(" ").append(VARCHAR) - .append(");"); Database.open().query(sql).compile().run(); } @@ -195,10 +212,16 @@ public class User { rs.getString(NAME), rs.getString(SALT), rs.getString(HASHED_PASS), + rs.getString(RESET_TOKEN), rs.getInt(PERMISSIONS))); return user; } + public String generateToken() throws SQLException { + token = Util.randomString(64); + Database.open().update(TABLE_NAME).set(RESET_TOKEN,token).where(EMAIL,this.email).compile().run(); + return token; + } /** * Loads the user identified by it's email, but only if the provided password matches. @@ -284,4 +307,18 @@ public class User { req.compile().run(); return this; } + + public void setPassword(String newPassword) throws SQLException { + if (newPassword != null) { + String newSalt = Util.sha256(email + LocalDateTime.now() + name); + String newHashedPass = Util.sha256(newPassword + newSalt); + Database.open().update(TABLE_NAME).set(HASHED_PASS,newHashedPass).set(SALT,newSalt).where(EMAIL,email).compile().run(); + hashedPass = newHashedPass; + salt = newSalt; + } + } + + public String token() { + return token; + } } diff --git a/src/main/java/de/srsoftware/widerhall/web/Web.java b/src/main/java/de/srsoftware/widerhall/web/Web.java index 573bb0e..12eca01 100644 --- a/src/main/java/de/srsoftware/widerhall/web/Web.java +++ b/src/main/java/de/srsoftware/widerhall/web/Web.java @@ -15,8 +15,7 @@ import java.io.IOException; import java.nio.file.Files; import java.security.InvalidKeyException; import java.sql.SQLException; -import java.util.HashMap; -import java.util.Map; +import java.util.*; import static de.srsoftware.widerhall.Constants.*; import static de.srsoftware.widerhall.Util.t; @@ -37,6 +36,7 @@ public class Web extends TemplateServlet { private static final String POST = "post"; private static final String REGISTER = "register"; private static final String RELOAD = "reload"; + private static final String RESET_PASSWORD = "reset-pw"; private static final String SUBSCRIBE = "subscribe"; private static final String UNSUBSCRIBE = "unsubscribe"; private static final String IMAP_HOST = "imap_host"; @@ -49,7 +49,7 @@ public class Web extends TemplateServlet { private static final String SMTP_PASS = "smtp_pass"; private static final int PRIMARY_KEY_CONSTRAINT = 19; - private String addList(HttpServletRequest req, HttpServletResponse resp) { + private String addList(HttpServletRequest req, HttpServletResponse resp, boolean isGet) { var user = Util.getUser(req); if (user == null) return redirectTo(LOGIN,resp); var data = new HashMap(); @@ -88,7 +88,7 @@ public class Web extends TemplateServlet { data.put(SMTP_PORT, smtpPort); if (name == null || name.isBlank() || email == null || email.isBlank()) { - data.put(ERROR, "List name and address are required!"); + data.put(ERROR, t("List name and address are required!")); return loadTemplate(ADD_LIST, data, resp); } @@ -98,7 +98,7 @@ public class Web extends TemplateServlet { } if (imapHost == null || imapHost.isBlank() || imapUser == null || imapUser.isBlank() || imapPass == null || imapPass.isBlank()) { - data.put(ERROR, "IMAP credentials are required!"); + data.put(ERROR, t("IMAP credentials are required!")); return loadTemplate(ADD_LIST, data, resp); } @@ -112,7 +112,7 @@ public class Web extends TemplateServlet { } if (smtpHost == null || smtpHost.isBlank() || smtpUser == null || smtpUser.isBlank() || smtpPass == null || smtpPass.isBlank()) { - data.put(ERROR, "SMTP credentials are required!"); + data.put(ERROR, t("SMTP credentials are required!")); return loadTemplate(ADD_LIST, data, resp); } @@ -139,15 +139,16 @@ public class Web extends TemplateServlet { var allowed = list.hasPublicArchive() || list.mayBeAlteredBy(user); if (!allowed) return t("You are not allowed to access the archive of this list"); - var map = new HashMap(); - map.put(LIST,list.email()); + var data = new HashMap(); + if (user != null) data.put(USER,user); + data.put(LIST,list.email()); var month = req.getParameter(MONTH); if (month != null && !month.isBlank()){ - map.put(MONTH,month); - map.put(MODERATOR,list.mayBeAlteredBy(user)); + data.put(MONTH,month); + data.put(MODERATOR,list.mayBeAlteredBy(user)); } - return loadTemplate(ARCHIVE,map,resp); + return loadTemplate(ARCHIVE,data,resp); } private String confirm(HttpServletRequest req, HttpServletResponse resp) { @@ -158,7 +159,7 @@ public class Web extends TemplateServlet { if (listMember != null) { listMember.sendConfirmationMail(getTemplate("confirmation_mail")); - return loadTemplate(INDEX,Map.of(USER,listMember.user().safeMap(),NOTES,"Confirmed list subscription!"),resp); + return loadTemplate(INDEX,Map.of(USER,listMember.user().safeMap(),NOTES,t("Confirmed list subscription!")),resp); } return t("Unknown user or token!"); } catch (Exception e) { @@ -169,17 +170,17 @@ public class Web extends TemplateServlet { @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { - String error = handleGet(req, resp); + String error = handleRequest(req, resp, true); if (error != null) resp.sendError(400,error); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { - String error = handlePost(req, resp); + String error = handleRequest(req, resp, false); if (error != null) resp.sendError(400,error); } - public String editList(HttpServletRequest req, HttpServletResponse resp) { + public String editList(HttpServletRequest req, HttpServletResponse resp, boolean isGet) { var user = Util.getUser(req); if (user == null) return redirectTo(LOGIN,resp); @@ -188,6 +189,7 @@ public class Web extends TemplateServlet { var list = Util.getMailingList(req); data.put(LIST,list.safeMap()); + if (isGet) return loadTemplate(EDIT_LIST,data,resp); try { var allowed = user.hashPermission(PERMISSION_ADMIN) || ListMember.load(list,user).isOwner(); if (!allowed) return loadTemplate(ADMIN,data,resp); @@ -220,7 +222,7 @@ public class Web extends TemplateServlet { data.put(SMTP_PORT, smtpPort); if (name == null || name.isBlank() || email == null || email.isBlank()) { - data.put(ERROR, "List name and address are required!"); + data.put(ERROR, t("List name and address are required!")); return loadTemplate(EDIT_LIST, data, resp); } @@ -230,7 +232,7 @@ public class Web extends TemplateServlet { } if (imapHost == null || imapHost.isBlank() || imapUser == null || imapUser.isBlank() || imapPass == null || imapPass.isBlank()) { - data.put(ERROR, "IMAP credentials are required!"); + data.put(ERROR, t("IMAP credentials are required!")); return loadTemplate(EDIT_LIST, data, resp); } @@ -244,7 +246,7 @@ public class Web extends TemplateServlet { } if (smtpHost == null || smtpHost.isBlank() || smtpUser == null || smtpUser.isBlank() || smtpPass == null || smtpPass.isBlank()) { - data.put(ERROR, "SMTP credentials are required!"); + data.put(ERROR, t("SMTP credentials are required!")); return loadTemplate(EDIT_LIST, data, resp); } @@ -259,7 +261,7 @@ public class Web extends TemplateServlet { list.update(name,email,imapHost,imapPort,imapUser,imapPass,inbox,smtpHost,smtpPort,smtpUser,smtpPass); return loadTemplate(ADMIN,data,resp); } catch (SQLException e) { - LOG.warn("Editing list {} by {} failed",list.email(),user.email(),e); + LOG.warn("Editing list {} by {} failed!",list.email(),user.email(),e); return t("Editing list {} by {} failed!",list.email(),user.email()); } } @@ -273,11 +275,56 @@ public class Web extends TemplateServlet { return sqle; } - private User getSessionUser(HttpServletRequest req) { - return req.getSession().getAttribute(USER) instanceof User user ? user : null; + private String handleLogin(HttpServletRequest req, HttpServletResponse resp,boolean isGet) { + if (isGet) return loadTemplate(LOGIN,null,resp); + var email = req.getParameter("email"); + var pass = req.getParameter("pass"); + if (email == null || pass == null) return loadTemplate(LOGIN, Map.of("error",t("Missing username or password!")), resp); + if (!Util.isEmail(email)) return loadTemplate(LOGIN, Map.of("error",t("'{}' is not a valid email address!",email)), resp); + try { + var user = User.loadUser(email,pass); + req.getSession().setAttribute("user",user); + // loading user successfull: goto index + resp.sendRedirect(String.join("/",WEB_ROOT,"admin")); + } catch (Exception e) { + try { + LOG.warn("Static.handleLogin failed:",e); + Thread.sleep(10000); + } finally { + return loadTemplate("login", Map.of(ERROR,t("Invalid username/password"),EMAIL,email), resp); + } + } + return null; } - private String handleGet(HttpServletRequest req, HttpServletResponse resp) { + private String handleNewPassword(HttpServletRequest req, HttpServletResponse resp, boolean isGet) { + var user = Util.getUser(req); + if (user == null) return redirectTo(LOGIN,resp); + var data = new HashMap(); + data.put(USER,user.safeMap()); + if (!isGet) { + var pass = req.getParameter(PASSWORD); + var repeat = req.getParameter(PASSWORD_REPEAT); + if (pass == null || pass.isBlank()) { + data.put(ERROR, t("Please set a password!")); + } else if (!pass.equals(repeat)) { + data.put(ERROR, t("Passwords do not match!")); + } else if (Util.simplePassword(pass)) { + data.put(ERROR, t("Your password is to simple!")); + } else { + try { + user.setPassword(pass); + data.put(NOTES,t("Your password has been updated!")); + return loadTemplate(ADMIN,data,resp); + } catch (SQLException e) { + data.put(ERROR,t("Failed to update password in database!")); + } + } + } + return loadTemplate(NEW_PASSWORD_FORM,data,resp); + } + + private String handleRequest(HttpServletRequest req, HttpServletResponse resp, boolean isGet){ var path = Util.getPath(req); var user = Util.getUser(req); var data = new HashMap(); @@ -285,39 +332,75 @@ public class Web extends TemplateServlet { if (user != null) data.put(USER,user.safeMap()); if (list != null) data.put(LIST,list.minimalMap()); + var interesting = !Set.of("js","jquery","css","OpenSans-Regular.woff","Bhineka.ttf").contains(path); + LOG.debug("{}",interesting?"interesting":"boring"); + if (user != null){ + switch (path){ + case ADD_LIST: + return addList(req,resp,isGet); + case ADMIN: + return loadTemplate(path,data,resp); + case EDIT_LIST: + return editList(req,resp,isGet); + case INSPECT: + return inspect(req,resp,isGet); + case NEW_PASSWORD_FORM: + return handleNewPassword(req,resp,isGet); + } + } switch (path){ case ARCHIVE: return archive(list,user,req,resp); + case CSS: + case INDEX: + return loadTemplate(path,data,resp); case CONFIRM: return confirm(req,resp); + case LOGIN: + return handleLogin(req,resp,isGet); + case LOGOUT: + req.getSession().invalidate(); + return redirectTo(INDEX,resp); case POST: return post(req,resp); + case REGISTER: + return registerUser(req,resp,isGet); case RELOAD: loadTemplates(); data.put(NOTES,t("Templates have been reloaded!")); - path = INDEX; - case CSS: - case INDEX: - return loadTemplate(path,data,resp); + return loadTemplate(INDEX,data,resp); + case RESET_PASSWORD: + if (!isGet) return resetPassword(req,resp); + // TODO: move following code into resetPassword method + try { + user = User.byToken(req.getParameter(TOKEN)); + if (user != null) { + req.getSession().setAttribute("user",user); + return redirectTo(NEW_PASSWORD_FORM,resp); + } + } catch (SQLException sqle){ + return loadTemplate(path,Map.of(ERROR,t("Failed to find user for token!")),resp); + } + var email = req.getParameter(EMAIL); + return loadTemplate(path,email == null ? null : Map.of(EMAIL,email),resp); + case SUBSCRIBE: + if (!isGet) { + return subscribe(req, resp); + } // TODO: Code für GET-Request mit in subscribe-Methode verschieben if (list.isOpenFor(user)) { data.put(LIST,list.email()); return loadTemplate(path, data, resp); } return t("You are not allowed to subscribe to '{}'!",list.email()); + case UNSUBSCRIBE: + return unsubscribe(req,resp,isGet); + } + /* uninteresting paths */ + switch (path){ case "js": resp.setContentType("text/javascript"); return loadTemplate(path,data,resp); - case LOGIN: - try { - if (User.noUsers()) return loadTemplate(REGISTER, Map.of(NOTES,t("User database is empty. Create admin user first:")), resp); - return loadTemplate(path,null,resp); - } catch (SQLException e) { - return "Error reading user database!"; - } - case LOGOUT: - req.getSession().invalidate(); - return redirectTo(INDEX,resp); case "jquery": resp.setContentType("text/javascript"); return loadFile("jquery-3.6.0.min.js",resp); @@ -329,69 +412,16 @@ public class Web extends TemplateServlet { return loadFile("Bhineka.ttf",resp); case UNSUBSCRIBE: data.put(LIST,list.email()); return loadTemplate(path,data,resp); - - } - - if (user != null){ - if (list != null) data.put(LIST,req.getParameter(LIST)); - switch (path){ - case EDIT_LIST: - return editList(req,resp); - } - return loadTemplate(path,data,resp); } return redirectTo(LOGIN,resp); } - private String handleLogin(HttpServletRequest req, HttpServletResponse resp) { - var email = req.getParameter("email"); - var pass = req.getParameter("pass"); - if (email == null || pass == null) return loadTemplate("login", Map.of("error",t("Missing username or password!")), resp); - if (!Util.isEmail(email)) return loadTemplate("login", Map.of("error",t("'{}' is not a valid email address!",email)), resp); - try { - var user = User.loadUser(email,pass); - req.getSession().setAttribute("user",user); - // loading user successfull: goto index - resp.sendRedirect(String.join("/",WEB_ROOT,"admin")); - } catch (Exception e) { - try { - LOG.warn("Static.handleLogin failed:",e); - Thread.sleep(10000); - } finally { - return loadTemplate("login", Map.of(ERROR,t("Invalid username/password"),EMAIL,email), resp); - } - } - return null; - } - - private String handlePost(HttpServletRequest req, HttpServletResponse resp) { - final var path = Util.getPath(req); - - switch (path){ - case ADD_LIST: - return addList(req,resp); - case EDIT_LIST: - return editList(req,resp); - case INSPECT: - return inspect(req,resp); - case LOGIN: - return handleLogin(req,resp); - case REGISTER: - return registerUser(req,resp); - case SUBSCRIBE: - return subscribe(req,resp); - case UNSUBSCRIBE: - return unsubscribe(req,resp); - } - - return t("No handler for path {}!",path); - } - - private String inspect(HttpServletRequest req, HttpServletResponse resp) { + private String inspect(HttpServletRequest req, HttpServletResponse resp, boolean isGet) { var user = Util.getUser(req); if (user == null) return redirectTo(LOGIN,resp); var data = new HashMap(); + data.put(USER,user); var error = false; var list = Util.getMailingList(req); @@ -399,6 +429,7 @@ public class Web extends TemplateServlet { error = true; data.put(ERROR, t("No valid mailing list provided!")); } else data.put(LIST, list.email()); + if (isGet) return loadTemplate(INSPECT,data,resp); if (!error && !list.mayBeAlteredBy(user)) { error = true; @@ -428,7 +459,7 @@ public class Web extends TemplateServlet { private String post(HttpServletRequest req, HttpServletResponse resp) { var id = req.getParameter(ID); - if (id == null) return t("Could not find email with id!"); + if (id == null) return t("Could not find email: missing id!"); try { var post = Post.load(id); var map = new HashMap(); @@ -451,10 +482,8 @@ public class Web extends TemplateServlet { } } - - - private String registerUser(HttpServletRequest req, HttpServletResponse resp) { - + private String registerUser(HttpServletRequest req, HttpServletResponse resp, boolean isGet) { + if (isGet) return loadTemplate(REGISTER,null,resp); var email = req.getParameter("email"); var pass = req.getParameter("pass"); var pass_repeat = req.getParameter("pass_repeat"); @@ -463,9 +492,9 @@ public class Web extends TemplateServlet { if (email == null || email.isBlank() || name == null || name.isBlank() || pass == null || pass.isBlank() || - pass_repeat == null || pass_repeat.isBlank()) return loadTemplate(REGISTER,Map.of(ERROR,t("Fill all fields, please!"),NAME,name,EMAIL,email),resp); - if (!pass.equals(pass_repeat)) return loadTemplate(REGISTER,Map.of(ERROR,t("Passwords do not match!"),NAME,name,EMAIL,email),resp); - if (Util.simplePassword(pass)) return loadTemplate(REGISTER,Map.of(ERROR,t("Password to short or to simple!"),NAME,name,EMAIL,email),resp); + pass_repeat == null || pass_repeat.isBlank()) return loadTemplate(REGISTER,Map.of(ERROR,t("Fill all fields, please!"),NAME,name,EMAIL,email),resp); + if (!pass.equals(pass_repeat)) return loadTemplate(REGISTER,Map.of(ERROR,t("Passwords do not match!"),NAME,name,EMAIL,email),resp); + if (Util.simplePassword(pass)) return loadTemplate(REGISTER,Map.of(ERROR,t("Password to short or to simple!"),NAME,name,EMAIL,email),resp); var firstUser = false; try { @@ -474,7 +503,6 @@ public class Web extends TemplateServlet { return t("Failed to access user database: {}",e.getMessage()); } - try { var user = User.create(email, name, pass); if (firstUser) user.addPermission(PERMISSION_ADMIN|User.PERMISSION_CREATE_LISTS); @@ -486,6 +514,39 @@ public class Web extends TemplateServlet { } } + private String resetPassword(HttpServletRequest req, HttpServletResponse resp) { + var email = req.getParameter("email"); + if (email == null) return loadTemplate("login", Map.of("error",t("Missing email address!")), resp); + if (!Util.isEmail(email)) return loadTemplate("login", Map.of("error",t("'{}' is not a valid email address!",email)), resp); + + try { + var user = User.load(email); + if (user != null) { + MailingList list = ListMember.listsOf(user).stream().map(ListMember::list).filter(ml -> ml.hasState(STATE_ENABLED)).findAny().orElse(null); + if (list == null) list = MailingList.listActive().stream().findAny().orElse(null); + if (list == null) throw new NullPointerException(t("no active List found!")); + String token = user.generateToken(); + var host = Arrays.stream(req.getRequestURL().toString().split("/")).filter(s -> !s.isEmpty() && !s.startsWith("http")).findFirst().orElse("unknwon host"); + + var link = req.getRequestURL().toString()+"?token="+token; + var subject = t("Reset your password at {}",host); + var text = t("Somebody asked to reset your account password at {}.",host)+ + "\n\n"+ + t("If that was you, please open the following link in your web browser:") + +"\n\n" + +link+ + "\n\n"+ + t("If you did not expect this email, please ignore it."); + + list.sendPasswordReset(email,subject,text); + return loadTemplate("reset_link_sent",null,resp); + } + } catch (Exception e){ + return loadTemplate(Util.getPath(req),Map.of(EMAIL,email,ERROR,t("Failed to send reset email:")+e.getMessage()),resp); + } + return null; + } + private String subscribe(HttpServletRequest req, HttpServletResponse resp) { var name = req.getParameter(NAME); var email = req.getParameter(EMAIL); @@ -499,12 +560,12 @@ public class Web extends TemplateServlet { if (list == null){ - data.put(ERROR,"No list provided by form data!"); + data.put(ERROR,t("No list provided by form data!")); return loadTemplate(SUBSCRIBE,data,resp); } if (name == null || name.isBlank() || email == null || email.isBlank()){ - data.put(ERROR,"Name and email are required fields for list subscription!"); + data.put(ERROR,t("Name and email are required fields for list subscription!")); return loadTemplate(SUBSCRIBE,data,resp); } if (pass != null && pass.isBlank()) pass = null; @@ -517,6 +578,7 @@ public class Web extends TemplateServlet { int code = cause.getErrorCode(); if (code == PRIMARY_KEY_CONSTRAINT) try {// user already exists user = User.loadUser(email,pass); + req.getSession().setAttribute("user",user); skipConfirmation = pass != null; // subscription with email address already known to database // success → subscribe } catch (InvalidKeyException | SQLException e) { @@ -555,11 +617,9 @@ public class Web extends TemplateServlet { } } - - - private String unsubscribe(HttpServletRequest req, HttpServletResponse resp) { + private String unsubscribe(HttpServletRequest req, HttpServletResponse resp, boolean isGet) { var data = new HashMap(); - var user = getSessionUser(req); + var user = Util.getUser(req); var email = req.getParameter(EMAIL); var list = Util.getMailingList(req); data.put(EMAIL,email); @@ -568,6 +628,7 @@ public class Web extends TemplateServlet { data.put(ERROR,t("No list provided by form data!")); return loadTemplate(UNSUBSCRIBE,data,resp); } else data.put(LIST,list.email()); + if (isGet) return loadTemplate(UNSUBSCRIBE,data,resp); if (user == null) { if (email == null || email.isBlank()) { data.put(ERROR, t("Email is required for list un-subscription!")); @@ -605,9 +666,8 @@ public class Web extends TemplateServlet { return loadTemplate(INDEX,data,resp); } catch (SQLException e) { LOG.warn("Problem during unscubsription of {} from {}:",user.email(),list.email(),e); - data.put(ERROR,"Failed to unsubscribe!"); + data.put(ERROR,t("Failed to unsubscribe!")); return loadTemplate(UNSUBSCRIBE,data,resp); - } } } diff --git a/src/main/resources/Application.de.translation b/src/main/resources/Application.de.translation index 41e9cac..02d40f5 100644 --- a/src/main/resources/Application.de.translation +++ b/src/main/resources/Application.de.translation @@ -11,14 +11,16 @@ archive : Archiv awaiting confirmation : erwartet Bestätigung Confirmation of list membership failed! : Bestätigung des Listen-Abonnements fehlgeschlagen! Confirmed list subscription! : Listen-Mitgliedschaft bestätigt! +Could not find email: missing id! : Konnte Email nicht finden: ID fehlt! enabled : aktiviert disabled : deaktiviert -Editing list {} by {} failed : Bearbeiten der Liste {} durch {} fehlgeschlagen! +Editing list {} by {} failed! : Bearbeiten der Liste {} durch {} fehlgeschlagen! Email is required for list un-subscription! : Für das Abbestellen ist eine E-Mail-Adresse erforderlich! Error reading user database! : Fehler beim Lesen der Nutzer-Datenbank! Failed to access user database\: {} : Fehler beim Zugriff auf die Nutzer-Datenbank: {} Failed to create list '{}'\: {} : Erzeugen der Liste '{}' fehlgeschlagen: {} Failed to create new user\: {} : Erzeugen des neuen Nutzers fehlgeschlagen: {} +Failed to find user for token! : Konnte keinen Nutzer zum Token finden! Failed to handle request\: {} : Konnte Anfrage nicht verarbeiten: {} Failed to load file '{}'! : Laden der Datei '{}' fehlgeschlagen! Failed to load list member for {}/{} : Laden der Mitgliedschaft zu {}/{} fehlgeschlagen! @@ -29,6 +31,7 @@ Failed to load user for {} : Laden des Nutzers zu {} fehlgeschlagen! Failed to load user for address {} : Laden des Nutzers für die Adresse {} fehlgeschlagen! Failed to make {} a moderator of {} : Ernennen von {} zum Moderator von {} fehlgeschlagen! Failed to make {} a subscriber of {} : Ernennen von {} zum regulären Abonnenten von {} fehlgeschlagen! +Failed to send reset email\: Versenden der Passwort-Zurücksetzen-Mail fehlgeschlagen: Failed to send email to {} : Senden der Email an {} fehlgeschlagen! Failed to send request confirmation email\: {} : Senden der Bestätigungs-Email fehlgeschlagen: {} Failed to send test email to {} : Senden der Test-Email an {} fehlgeschlagen! @@ -37,15 +40,18 @@ Failed to un-subscribe {} from {} : Abbestellen von {} / {} fehlgeschlagen! Failed to unsubscribe! : Abbestellen der Mailin-Liste fehlgeschlagen! Failed to update list '{}' : Aktualisieren der Liste '{}' fehlgeschlagen! Failed to update MailingList! : Aktualisierung der Mailing-Liste fehlgeschlagen! +Failed to update password in database! : Aktualisierung des Passworts in der Datenbank fehlgeschlagen! Fill all fields, please! : Bitte alle Felder ausfüllen! File {} does not exist! : Datei {} existiert nicht! Find the forwarded message in the attachment(s)!\n : Die weitergeleitete Nachricht findest du im Anhang dieser E-Mail!\n forward_attached : als Anhang weiterleiten hidden : versteckt hide_receivers : Empfänger verbergen +If that was you, please open the following link in your web browser\: : Falls Sie das waren, öffnen Sie den folgenden Link in einem Web-Browser: +If you did not expect this email, please ignore it. : Falls Sie diese Email nicht angefordert hatten, können Sie sie einfach ignorieren. If you received this mail, the SMTP settings of your mailing list are correct. : Wenn Sie diese Nachricht empfangen haben, sind die SMTP-Einstellungen Ihrer Mailing-Liste korrekt. IMAP credentials are required! : IMAP-Zugangsdaten sind erforderlich! -Invalid or missing token : Ungültiger oder fehlender Token! +Invalid or missing token! : Ungültiger oder fehlender Token! Invalid username/password : Ungültiger Nutzername oder ungültiges Passwort! Invalid email/password combination! : Ungültige E-Mail-/Passwort-Kombination! List '{}' requires attention! : Liste '{}' erfordert Aufmerksamkeit! @@ -54,12 +60,14 @@ List email ({}) is not a valid email address! : Listen-E-Mail-Adresse ({}) ist k List name and address are required! : Name und Adresse der Liste sind notwendige Felder! made public : veröffentlicht Mailing list '{}' was {}! : Mailing-Liste '{}' wurde {}! +Missing email address! : Email-Adresse fehlt! Message deleted : Nachricht gelöscht missing user email address! : E-Mail-Adresse des Listenmitglieds nicht angegeben! Missing username or password! : Nutzername oder Passwort fehlen! moderator : Moderator Name and email are required fields for list subscription! : Name und E-Mail-Adresse sind für das Abonnieren der Mailingliste erforderlich! no : nein +no active List found! : keine aktive Verteilerliste gefunden! No handler for path {}! : Kein Handler für den Pfad '{}'! no list email provided! : Keine Listen-Email übertragen! No list provided by form data! : Formular-Daten enthalten keine Liste! @@ -74,21 +82,24 @@ original_from : ursprünglicher Absender owner : Besitzer Passwords do not match! : Passworte stimmen nicht überein! Password to short or to simple! : Passwort zu kurz oder zu einfach! +Please set a password! : Bitte geben Sie ein Passwort ein! Problem during unscubsription of {} from {}\: : Es ist ein Problem beim Austragen von {} aus der Liste {} aufgetreten: public : öffentlich Query '{}' failed\: : Query '{}' fehlgeschlagen: reply_to_list : Antwort an Liste +Reset your password at {} : Passwort auf {} zurücksetzen Sent confirmation mail to '{}. : Bestätigungs-Email wurde an '{} versendet. +Somebody asked to reset your account password at {}. : Jemand hat eine Passwort-Änderung für Ihren Account auf {} angefordert. SMTP credentials are required! : SMTP-Zugangsdaten sind erforderlich! subscriber : Abonniert Subscription failed\: {} : Abonnieren der Liste fehlgeschlagen: {} Sucessfully updated MailingList! : Mailing-Liste aktualisiert! Successfully subscribed '{}' to '{}'. : '{}' hat die Mailingliste '{}' erfolgreich abonniert. Sucessfully un-subscribed from '{}'. : '{}' erfolgreich abbestellt. -Templates have been reloaded : Vorlagen wurden neu geladen! +Templates have been reloaded! : Vorlagen wurden neu geladen! The mailing list you are trying to view does not exist! : Die Mailingliste, auf die Sie zugreifen wollen, gibt es nicht! This list received an email from {}, who is not member of the list.\nThe email has been moved to the '{}' folder.\nYou may manually forward this message or drop it. : Diese Liste hat eine E-Mail von {} empfangen. Der Absender ist nicht Mitglied der Liste.\nDie Email wurde in den '{}'-Ordner verschoben.\nSie können die Nachricht manuell weiterleiten oder verwerfen. -Unknown user or token : Nutzer oder Token unbekannt! +Unknown user or token! : Nutzer oder Token unbekannt! Updated user permissions : Nutzer-Berechtigungen aktualisiert User database is empty. Create admin user first\: : Nutzer-Datenbank ist leer. Admin-Nutzer wird hiermit angelegt: Was not able to check existence of table {}! : Konnte Existenz der Tabelle {} nicht prüfen! @@ -96,6 +107,7 @@ Was not able to redirect to {} page\: {} : Weiterleitung nach {} fehlgeschlagen: yes : ja You already are member of this list! : Sie haben diese Liste bereits abonniert! You are not allowed to access the archive of this list! : Du hast keine Berechtigung, das Archiv dieser Liste anzusehen! +You are not allowed to alter settings of this list! : Du hast keine Berechtigung, die Einstellungen dieser Liste zu ändern! You are not allowed to alter user permissions! : Sie haben nicht die Berechtigung, um Berechtigungen zu ändern! You are not allowed to create new mailing lists! : Ihnen ist es nicht gestattet, neue Mailinglisten anzulegen! You are not allowed to edit '{}' : Du bist nicht berechtigt, '{}' zu bearbeiten! @@ -113,3 +125,5 @@ You are trying to access a non-existing list! : Du versuchst auf eine nicht exis You have tried to send a message to the list '{}', which failed. This is because you are not a (privileged) member of this list.\n : Sie haben versucht, eine Nachricht an die Liste '{}' zu senden. Das wurde verweigert, da Sie kein Mitglied der Liste (mit entsprechenden Berechtigungen) sind.\n You may go to {} and subscribe to the list, then try again. : Sie können zu {} gehen und die Liste abonnieren. Versuchen Sie es danach erneut. Your message to {} was rejected! : Ihre Nachricht an {} wurde zurückgewiesen! +Your password is to simple! : Ihr Passwort ist zu einfach! +Your password has been updated! : Ihr Passwort wurde aktualisiert! \ No newline at end of file diff --git a/src/test/java/de/srsoftware/widerhall/UtilTest.java b/src/test/java/de/srsoftware/widerhall/UtilTest.java index 412e2c4..a7e353f 100644 --- a/src/test/java/de/srsoftware/widerhall/UtilTest.java +++ b/src/test/java/de/srsoftware/widerhall/UtilTest.java @@ -4,6 +4,7 @@ import junit.framework.TestCase; import java.util.Map; import java.util.TreeMap; +import static de.srsoftware.widerhall.Util.*; public class UtilTest extends TestCase { @@ -17,17 +18,17 @@ public class UtilTest extends TestCase { } public void testHex(){ - assertEquals("00",Util.hex(0)); - assertEquals("09",Util.hex(9)); - assertEquals("0A",Util.hex(10)); - assertEquals("0F",Util.hex(15)); - assertEquals("10",Util.hex(16)); - assertEquals("19",Util.hex(25)); - assertEquals("FF",Util.hex(255)); + assertEquals("00",hex(0)); + assertEquals("09",hex(9)); + assertEquals("0A",hex(10)); + assertEquals("0F",hex(15)); + assertEquals("10",hex(16)); + assertEquals("19",hex(25)); + assertEquals("FF",hex(255)); } public void testSha256() { - assertEquals("9F722959A023C02A3BA0FAFDBA81ADED642D6610EFF5DCA32DCE35132E16B6C5",Util.sha256("Dies ist ein Test")); + assertEquals("9F722959A023C02A3BA0FAFDBA81ADED642D6610EFF5DCA32DCE35132E16B6C5",sha256("Dies ist ein Test")); } public void testTranslate() { @@ -35,30 +36,40 @@ public class UtilTest extends TestCase { } public void testIsEmail() { - assertFalse(Util.isEmail("Test")); - assertFalse(Util.isEmail("Test@")); - assertFalse(Util.isEmail("@Test")); - assertTrue(Util.isEmail("Test@Domain")); - assertFalse(Util.isEmail("Test@Domain@Test")); + assertFalse(isEmail("Test")); + assertFalse(isEmail("Test@")); + assertFalse(isEmail("@Test")); + assertTrue(isEmail("Test@Domain")); + assertFalse(isEmail("Test@Domain@Test")); } public void testSimplePassword() { - assertTrue(Util.simplePassword("$@%€#")); // too short - assertTrue(Util.simplePassword("test123")); // no special chars - assertFalse(Util.simplePassword("test$23")); // contains special chars - assertTrue(Util.simplePassword("skgjafdsg")); // chars only - assertTrue(Util.simplePassword("986535465")); // digits only - assertFalse(Util.simplePassword("test9523")); // mixed digits and chars - assertFalse(Util.simplePassword("8986546054")); // digits only, but long enough - assertFalse(Util.simplePassword("salgksdjbw")); // chars only, but long enough + assertTrue(simplePassword("$@%€#")); // too short + assertTrue(simplePassword("test123")); // no special chars + assertFalse(simplePassword("test$23")); // contains special chars + assertTrue(simplePassword("skgjafdsg")); // chars only + assertTrue(simplePassword("986535465")); // digits only + assertFalse(simplePassword("test9523")); // mixed digits and chars + assertFalse(simplePassword("8986546054")); // digits only, but long enough + assertFalse(simplePassword("salgksdjbw")); // chars only, but long enough } public void testUnsetFlags(){ - assertEquals(0,Util.unset(31,1,2,4,8,16)); - assertEquals(1,Util.unset(31,2,4,8,16)); - assertEquals(2,Util.unset(31,1,4,8,16)); - assertEquals(4,Util.unset(31,1,2,8,16)); - assertEquals(8,Util.unset(31,1,2,4,16)); - assertEquals(16,Util.unset(31,1,2,4,8)); + assertEquals(0,unset(31,1,2,4,8,16)); + assertEquals(1,unset(31,2,4,8,16)); + assertEquals(2,unset(31,1,4,8,16)); + assertEquals(4,unset(31,1,2,8,16)); + assertEquals(8,unset(31,1,2,4,16)); + assertEquals(16,unset(31,1,2,4,8)); + } + + public void testBoundedChar(){ + assertEquals('0',boundedChar(0)); + assertEquals('9',boundedChar(9)); + assertEquals('A',boundedChar(10)); + assertEquals('Z',boundedChar(35)); + assertEquals('a',boundedChar(36)); + assertEquals('z',boundedChar(61)); + assertEquals('0',boundedChar(62)); } } \ No newline at end of file diff --git a/src/test/java/de/srsoftware/widerhall/data/ListMemberTest.java b/src/test/java/de/srsoftware/widerhall/data/ListMemberTest.java index 3aa457f..eebf481 100644 --- a/src/test/java/de/srsoftware/widerhall/data/ListMemberTest.java +++ b/src/test/java/de/srsoftware/widerhall/data/ListMemberTest.java @@ -110,7 +110,7 @@ public class ListMemberTest extends TestCase { }*/ public void testSafeMap() { - var user = new User("email","name","salt","hash",0); + var user = new User("email","name","salt","hash",null,0); var lm = new ListMember(null,user,ListMember.STATE_AWAITING_CONFIRMATION,"token"); assertEquals(Map.of(EMAIL,"email",NAME,"name",STATE,"erwartet Bestätigung"),lm.safeMap()); } @@ -137,7 +137,7 @@ public class ListMemberTest extends TestCase { }*/ public void testUser() { - var user = new User("email","name","salt","hash",0); + var user = new User("email","name","salt","hash",null,0); var lm = new ListMember(null,user,ListMember.STATE_AWAITING_CONFIRMATION,"token"); assertEquals(user,lm.user()); } diff --git a/static/templates/add_list.st b/static/templates/add_list.st index 0674991..78c1698 100644 --- a/static/templates/add_list.st +++ b/static/templates/add_list.st @@ -5,7 +5,7 @@ «navigation()» «userinfo()» «messages()» -

Widerhall List Creation

+

Widerhall Listen-Erzeugung

«list_data_form()» \ No newline at end of file diff --git a/static/templates/edit_list.st b/static/templates/edit_list.st index 9ab3ba0..ca97fc2 100644 --- a/static/templates/edit_list.st +++ b/static/templates/edit_list.st @@ -5,7 +5,7 @@ «navigation()» «userinfo()» «messages()» -

Widerhall List Setup

+

Widerhall Listen-Bearbeitung

«list_data_form()» «footer()» diff --git a/static/templates/login.st b/static/templates/login.st index fdec507..51f8311 100644 --- a/static/templates/login.st +++ b/static/templates/login.st @@ -19,5 +19,6 @@ + Forgot password? \ No newline at end of file diff --git a/static/templates/new_password_form.st b/static/templates/new_password_form.st new file mode 100644 index 0000000..f365a8b --- /dev/null +++ b/static/templates/new_password_form.st @@ -0,0 +1,33 @@ + + + «head()» + «userinfo()» + + + «navigation()» + «messages()» +

neues Account-Passwort setzen

+
+
+ + Hallo «if(data.user.name)»«data.user.name»«else»«data.user.email»«endif»! Du kannst hier ein neues Passwort für deinen Account «data.email» setzen: + Login-Daten + + + + + +
+
+ Passwort vergessen? + + \ No newline at end of file diff --git a/static/templates/reset-pw.st b/static/templates/reset-pw.st new file mode 100644 index 0000000..0ecc464 --- /dev/null +++ b/static/templates/reset-pw.st @@ -0,0 +1,22 @@ + + + «head()» + + «navigation()» + «messages()» +

Widerhall Passwort-Hilfe

+
+
+ Passwort verloren? + Falls Sie ihr Passwort vergessen oder verloren haben, geben Sie bitte unten Ihre Emailadresse ein. + Nach dem Klicken des „Passwort zurücksetzen“-Links bekommen Sie eine Email mit einem Link. + Dieser Link erlaubt es Ihnen, ein neues Passwort zu vergeben. + + +
+
+ + \ No newline at end of file diff --git a/static/templates/reset_link_sent.st b/static/templates/reset_link_sent.st new file mode 100644 index 0000000..e5366ad --- /dev/null +++ b/static/templates/reset_link_sent.st @@ -0,0 +1,18 @@ + + + «head()» + + «navigation()» + «messages()» + +

Bestätigung

+
+ + Link gesendet + + Ihnen wurde eine Email gesendet:
+ Diese Mail enthält einen Link, welches beweist, dass Sie diese Emailadresse besitzen. + Wenn Sie den empfangenen Link in einem Webbrowser öffnen, können Sie ein neues Passwort vergeben. +
+ + \ No newline at end of file diff --git a/static/templates/subscribe.st b/static/templates/subscribe.st index 242a1e0..8faa789 100644 --- a/static/templates/subscribe.st +++ b/static/templates/subscribe.st @@ -11,11 +11,11 @@
Abonnieren von "«data.list»"