Browse Source

implemented resetting passwords

Signed-off-by: Stephan Richter <s.richter@srsoftware.de>
main
Stephan Richter 6 months ago
parent
commit
3c864a12ed
  1. 2
      src/main/java/de/srsoftware/widerhall/Constants.java
  2. 24
      src/main/java/de/srsoftware/widerhall/Util.java
  3. 24
      src/main/java/de/srsoftware/widerhall/data/Database.java
  4. 21
      src/main/java/de/srsoftware/widerhall/data/MailingList.java
  5. 47
      src/main/java/de/srsoftware/widerhall/data/User.java
  6. 246
      src/main/java/de/srsoftware/widerhall/web/Web.java
  7. 65
      src/test/java/de/srsoftware/widerhall/UtilTest.java
  8. 4
      src/test/java/de/srsoftware/widerhall/data/ListMemberTest.java
  9. 1
      static/templates/login.st
  10. 33
      static/templates/new_password_form.st
  11. 21
      static/templates/reset-pw.st
  12. 17
      static/templates/reset_link_sent.st
  13. 4
      static/templates/subscribe.st

2
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 MESSAGE_ID = "message_id";
public static final String MODERATOR = "moderator"; public static final String MODERATOR = "moderator";
public static final String MONTH = "month"; 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 NOTES = "notes";
public static final String PASSWORD = "password"; public static final String PASSWORD = "password";
public static final String PASSWORD_REPEAT = "password-repeat";
public static final String PERMISSIONS = "permissions"; public static final String PERMISSIONS = "permissions";
public static final Object PORT = "port"; public static final Object PORT = "port";
public static final String PREFIX = "prefix"; public static final String PREFIX = "prefix";

24
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.MailingList;
import de.srsoftware.widerhall.data.User; import de.srsoftware.widerhall.data.User;
import javax.mail.Message;
import javax.mail.MessagingException; import javax.mail.MessagingException;
import javax.mail.Multipart; import javax.mail.Multipart;
import javax.mail.Part; import javax.mail.Part;
import javax.mail.internet.AddressException;
import javax.mail.internet.InternetAddress;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.io.IOException; import java.io.IOException;
import java.net.URLEncoder; import java.net.URLEncoder;
@ -19,6 +16,7 @@ import java.security.NoSuchAlgorithmException;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.Map; import java.util.Map;
import java.util.Random;
import java.util.stream.Collectors; import java.util.stream.Collectors;
import static de.srsoftware.widerhall.Constants.*; import static de.srsoftware.widerhall.Constants.*;
@ -28,6 +26,14 @@ public class Util {
private static final MessageDigest SHA256 = getSha256(); 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-]+)*$"; 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) { public static String dropEmail(String tx) {
return tx.replaceAll( "[.\\-\\w]+@[.\\-\\w]+", "[email_removed]"); return tx.replaceAll( "[.\\-\\w]+@[.\\-\\w]+", "[email_removed]");
} }
@ -119,6 +125,18 @@ public class Util {
return email.matches(EMAIL_PATTERN); return email.matches(EMAIL_PATTERN);
} }
public static String randomString(int length) {
Random rand = new Random();
StringBuilder sb = new StringBuilder();
for (int i=0; i<length; i++) {
int k = rand.nextInt();
char c = boundedChar(k);
System.out.println("adding '"+c+"'…");
sb.append(c);
}
return sb.toString();
}
public static String sha256(String s) { public static String sha256(String s) {
byte[] bytes = SHA256.digest(s.getBytes(StandardCharsets.UTF_8)); byte[] bytes = SHA256.digest(s.getBytes(StandardCharsets.UTF_8));
return hex(bytes); return hex(bytes);

24
src/main/java/de/srsoftware/widerhall/data/Database.java

@ -10,6 +10,7 @@ import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.*; import java.util.*;
import static de.srsoftware.widerhall.Constants.*;
import static de.srsoftware.widerhall.Util.t; import static de.srsoftware.widerhall.Util.t;
import static de.srsoftware.widerhall.data.MailingList.HOLD_TIME; import static de.srsoftware.widerhall.data.MailingList.HOLD_TIME;
@ -19,6 +20,7 @@ import static de.srsoftware.widerhall.data.MailingList.HOLD_TIME;
*/ */
public class Database { public class Database {
private static final Logger LOG = LoggerFactory.getLogger(Database.class); private static final Logger LOG = LoggerFactory.getLogger(Database.class);
private static final String DB_VERSION = "db_version";
private static Database singleton = null; // we only need one db handle ever. This will be it. private static Database singleton = null; // we only need one db handle ever. This will be it.
private final Connection conn; // the actual db connection handle within the singleton private final Connection conn; // the actual db connection handle within the singleton
@ -295,6 +297,14 @@ public class Database {
return this; return this;
} }
private void createVersionTable() throws SQLException {
var sql = "CREATE TABLE %s (%s %s NOT NULL PRIMARY KEY);".formatted(DB_VERSION,DB_VERSION,INT);
var db = Database.open();
db.query(sql).compile().run();
sql = "INSERT INTO %s VALUES (1)".formatted(DB_VERSION);
db.query(sql).compile().run();
}
private boolean columnExists(String tableName, String columnName) throws SQLException { private boolean columnExists(String tableName, String columnName) throws SQLException {
var rs = Database.open().select("pragma_table_info('"+tableName+"')","COUNT(*) AS num").where("name",columnName).compile().exec(); var rs = Database.open().select("pragma_table_info('"+tableName+"')","COUNT(*) AS num").where("name",columnName).compile().exec();
try { try {
@ -357,6 +367,7 @@ public class Database {
try { try {
singleton = new Database(DriverManager.getConnection(url)); singleton = new Database(DriverManager.getConnection(url));
singleton.assertTables(); // must not be concatenated to exception above (assertTables accesses singleton)! singleton.assertTables(); // must not be concatenated to exception above (assertTables accesses singleton)!
singleton.update202405();
} catch (SQLException sqle) { } catch (SQLException sqle) {
sqle.printStackTrace(); sqle.printStackTrace();
} }
@ -373,6 +384,10 @@ public class Database {
return new Request(sql); return new Request(sql);
} }
public Request query(String sql) {
return new Request(new StringBuilder(sql));
}
/** /**
* create a SELECT [flields] FROM [table] request. * create a SELECT [flields] FROM [table] request.
* If no fields are supplied, a request in the form SELECT * FROM [table] will be generated. * If no fields are supplied, a request in the form SELECT * FROM [table] will be generated.
@ -390,7 +405,6 @@ public class Database {
return new Request(sql.append(" FROM ").append(tableName)); return new Request(sql.append(" FROM ").append(tableName));
} }
/** /**
* check, whether a table with the provided name exists * check, whether a table with the provided name exists
* @param tbName * @param tbName
@ -420,4 +434,12 @@ public class Database {
public Request update(String tableName) { public Request update(String tableName) {
return new Request(new StringBuilder("UPDATE ").append(tableName)); return new Request(new StringBuilder("UPDATE ").append(tableName));
} }
private Database update202405() throws SQLException {
if (!tableExists(Database.DB_VERSION)) {
createVersionTable();
User.addTokenColumn();
}
return this;
}
} }

21
src/main/java/de/srsoftware/widerhall/data/MailingList.java

@ -49,8 +49,8 @@ public class MailingList implements MessageHandler, ProblemListener {
private static final String SMTP_PASS = "smtp_pass"; private static final String SMTP_PASS = "smtp_pass";
public static final String TABLE_NAME = "Lists"; public static final String TABLE_NAME = "Lists";
private static final int STATE_PENDING = 0; private static final int STATE_PENDING = 0;
private static final int STATE_ENABLED = 1; // do we process incoming messages? public static final int STATE_ENABLED = 1; // do we process incoming messages?
private static final int STATE_PUBLIC = 2; // can guests see this ML? public static final int STATE_PUBLIC = 2; // can guests see this ML?
public static final int STATE_FORWARD_FROM = 4; // set original sender as FROM when forwarding? public static final int STATE_FORWARD_FROM = 4; // set original sender as FROM when forwarding?
public static final int STATE_FORWARD_ATTACHED = 8; // forward messages as attachment? public static final int STATE_FORWARD_ATTACHED = 8; // forward messages as attachment?
public static final int STATE_HIDE_RECEIVERS = 16; // send using BCC receivers public static final int STATE_HIDE_RECEIVERS = 16; // send using BCC receivers
@ -283,6 +283,18 @@ public class MailingList implements MessageHandler, ProblemListener {
return hasState(STATE_OPEN_FOR_GUESTS|STATE_OPEN_FOR_SUBSCRIBERS); return hasState(STATE_OPEN_FOR_GUESTS|STATE_OPEN_FOR_SUBSCRIBERS);
} }
public static List<MailingList> listActive() {
try {
var list = new ArrayList<MailingList>();
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. * 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. * 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; return ml;
} }
/** /**
* Load a ML object by it's identifying email address. * 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. * 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(){ protected SmtpClient smtp(){
return smtp; return smtp;
} }

47
src/main/java/de/srsoftware/widerhall/data/User.java

@ -6,6 +6,7 @@ import java.security.InvalidKeyException;
import java.sql.ResultSet; import java.sql.ResultSet;
import java.sql.SQLException; import java.sql.SQLException;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime;
import java.util.*; import java.util.*;
import static de.srsoftware.widerhall.Constants.*; 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_ADMIN = 1;
public static final int PERMISSION_CREATE_LISTS = 2; public static final int PERMISSION_CREATE_LISTS = 2;
public static final String HASHED_PASS = "hashedPassword"; public static final String HASHED_PASS = "hashedPassword";
public static final String RESET_TOKEN = "resetToken";
public static final String SALT = "salt"; public static final String SALT = "salt";
private static final HashMap<String,User> users = new HashMap<>(); private static final HashMap<String,User> users = new HashMap<>();
private String email, salt, hashedPass, name; private String email, salt, hashedPass, name, token;
private int permissions; private int permissions;
/** /**
@ -34,11 +36,12 @@ public class User {
* @param hashedPass * @param hashedPass
* @param permissions * @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.email = email.toLowerCase();
this.name = name; this.name = name;
this.salt = salt; this.salt = salt;
this.hashedPass = hashedPass; this.hashedPass = hashedPass;
this.token = token;
this.permissions = permissions; this.permissions = permissions;
} }
@ -81,6 +84,21 @@ public class User {
.run(); .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. * 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 salt = null;
String hashedPass = null; String hashedPass = null;
if (password != null) { if (password != null) {
salt = Util.sha256(email + name + LocalDate.now()); salt = Util.sha256(email + LocalDateTime.now() + name);
hashedPass = Util.sha256(password + salt); 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(PERMISSIONS).append(" ").append(INT).append(", ")
.append(SALT).append(" ").append(VARCHAR).append(", ") .append(SALT).append(" ").append(VARCHAR).append(", ")
.append(HASHED_PASS).append(" ").append(VARCHAR) .append(HASHED_PASS).append(" ").append(VARCHAR)
.append(");"); .append(");");
Database.open().query(sql).compile().run(); Database.open().query(sql).compile().run();
} }
@ -195,10 +212,16 @@ public class User {
rs.getString(NAME), rs.getString(NAME),
rs.getString(SALT), rs.getString(SALT),
rs.getString(HASHED_PASS), rs.getString(HASHED_PASS),
rs.getString(RESET_TOKEN),
rs.getInt(PERMISSIONS))); rs.getInt(PERMISSIONS)));
return user; 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. * 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(); req.compile().run();
return this; 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;
}
} }

246
src/main/java/de/srsoftware/widerhall/web/Web.java

@ -15,8 +15,7 @@ import java.io.IOException;
import java.nio.file.Files; import java.nio.file.Files;
import java.security.InvalidKeyException; import java.security.InvalidKeyException;
import java.sql.SQLException; import java.sql.SQLException;
import java.util.HashMap; import java.util.*;
import java.util.Map;
import static de.srsoftware.widerhall.Constants.*; import static de.srsoftware.widerhall.Constants.*;
import static de.srsoftware.widerhall.Util.t; 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 POST = "post";
private static final String REGISTER = "register"; private static final String REGISTER = "register";
private static final String RELOAD = "reload"; private static final String RELOAD = "reload";
private static final String RESET_PASSWORD = "reset-pw";
private static final String SUBSCRIBE = "subscribe"; private static final String SUBSCRIBE = "subscribe";
private static final String UNSUBSCRIBE = "unsubscribe"; private static final String UNSUBSCRIBE = "unsubscribe";
private static final String IMAP_HOST = "imap_host"; 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 String SMTP_PASS = "smtp_pass";
private static final int PRIMARY_KEY_CONSTRAINT = 19; 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); var user = Util.getUser(req);
if (user == null) return redirectTo(LOGIN,resp); if (user == null) return redirectTo(LOGIN,resp);
var data = new HashMap<String, Object>(); var data = new HashMap<String, Object>();
@ -139,15 +139,16 @@ public class Web extends TemplateServlet {
var allowed = list.hasPublicArchive() || list.mayBeAlteredBy(user); var allowed = list.hasPublicArchive() || list.mayBeAlteredBy(user);
if (!allowed) return t("You are not allowed to access the archive of this list"); if (!allowed) return t("You are not allowed to access the archive of this list");
var map = new HashMap<String,Object>(); var data = new HashMap<String,Object>();
map.put(LIST,list.email()); if (user != null) data.put(USER,user);
data.put(LIST,list.email());
var month = req.getParameter(MONTH); var month = req.getParameter(MONTH);
if (month != null && !month.isBlank()){ if (month != null && !month.isBlank()){
map.put(MONTH,month); data.put(MONTH,month);
map.put(MODERATOR,list.mayBeAlteredBy(user)); data.put(MODERATOR,list.mayBeAlteredBy(user));
} }
return loadTemplate(ARCHIVE,map,resp); return loadTemplate(ARCHIVE,data,resp);
} }
private String confirm(HttpServletRequest req, HttpServletResponse resp) { private String confirm(HttpServletRequest req, HttpServletResponse resp) {
@ -169,17 +170,17 @@ public class Web extends TemplateServlet {
@Override @Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { 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); if (error != null) resp.sendError(400,error);
} }
@Override @Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { 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); 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); var user = Util.getUser(req);
if (user == null) return redirectTo(LOGIN,resp); if (user == null) return redirectTo(LOGIN,resp);
@ -188,6 +189,7 @@ public class Web extends TemplateServlet {
var list = Util.getMailingList(req); var list = Util.getMailingList(req);
data.put(LIST,list.safeMap()); data.put(LIST,list.safeMap());
if (isGet) return loadTemplate(EDIT_LIST,data,resp);
try { try {
var allowed = user.hashPermission(PERMISSION_ADMIN) || ListMember.load(list,user).isOwner(); var allowed = user.hashPermission(PERMISSION_ADMIN) || ListMember.load(list,user).isOwner();
if (!allowed) return loadTemplate(ADMIN,data,resp); if (!allowed) return loadTemplate(ADMIN,data,resp);
@ -273,11 +275,56 @@ public class Web extends TemplateServlet {
return sqle; return sqle;
} }
private User getSessionUser(HttpServletRequest req) { private String handleLogin(HttpServletRequest req, HttpServletResponse resp,boolean isGet) {
return req.getSession().getAttribute(USER) instanceof User user ? user : null; 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<String,Object>();
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, "Please set a password!");
} else if (!pass.equals(repeat)) {
data.put(ERROR, "Passwords do not match");
} else if (Util.simplePassword(pass)) {
data.put(ERROR, "Your password is to simple");
} else {
try {
user.setPassword(pass);
data.put(NOTES,"Your password has been updated!");
return loadTemplate(ADMIN,data,resp);
} catch (SQLException e) {
data.put(ERROR,t("Failed to update password in database: {}",e.getMessage()));
}
}
}
return loadTemplate(NEW_PASSWORD_FORM,data,resp);
}
private String handleRequest(HttpServletRequest req, HttpServletResponse resp, boolean isGet){
var path = Util.getPath(req); var path = Util.getPath(req);
var user = Util.getUser(req); var user = Util.getUser(req);
var data = new HashMap<String,Object>(); var data = new HashMap<String,Object>();
@ -285,39 +332,75 @@ public class Web extends TemplateServlet {
if (user != null) data.put(USER,user.safeMap()); if (user != null) data.put(USER,user.safeMap());
if (list != null) data.put(LIST,list.minimalMap()); 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){ switch (path){
case ARCHIVE: case ARCHIVE:
return archive(list,user,req,resp); return archive(list,user,req,resp);
case CSS:
case INDEX:
return loadTemplate(path,data,resp);
case CONFIRM: case CONFIRM:
return confirm(req,resp); return confirm(req,resp);
case LOGIN:
return handleLogin(req,resp,isGet);
case LOGOUT:
req.getSession().invalidate();
return redirectTo(INDEX,resp);
case POST: case POST:
return post(req,resp); return post(req,resp);
case REGISTER:
return registerUser(req,resp,isGet);
case RELOAD: case RELOAD:
loadTemplates(); loadTemplates();
data.put(NOTES,t("Templates have been reloaded!")); data.put(NOTES,t("Templates have been reloaded!"));
path = INDEX; return loadTemplate(INDEX,data,resp);
case CSS: case RESET_PASSWORD:
case INDEX: if (!isGet) return resetPassword(req,resp);
return loadTemplate(path,data,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,"Failed to add user for token!"),resp);
}
var email = req.getParameter(EMAIL);
return loadTemplate(path,email == null ? null : Map.of(EMAIL,email),resp);
case SUBSCRIBE: case SUBSCRIBE:
if (!isGet) {
return subscribe(req, resp);
} // TODO: Code für GET-Request mit in subscribe-Methode verschieben
if (list.isOpenFor(user)) { if (list.isOpenFor(user)) {
data.put(LIST,list.email()); data.put(LIST,list.email());
return loadTemplate(path, data, resp); return loadTemplate(path, data, resp);
} }
return t("You are not allowed to subscribe to '{}'!",list.email()); return t("You are not allowed to subscribe to '{}'!",list.email());
case UNSUBSCRIBE:
return unsubscribe(req,resp,isGet);
}
/* uninteresting paths */
switch (path){
case "js": case "js":
resp.setContentType("text/javascript"); resp.setContentType("text/javascript");
return loadTemplate(path,data,resp); 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": case "jquery":
resp.setContentType("text/javascript"); resp.setContentType("text/javascript");
return loadFile("jquery-3.6.0.min.js",resp); 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: return loadFile("Bhineka.ttf",resp); case UNSUBSCRIBE:
data.put(LIST,list.email()); data.put(LIST,list.email());
return loadTemplate(path,data,resp); 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); return redirectTo(LOGIN,resp);
} }
private String handleLogin(HttpServletRequest req, HttpServletResponse resp) { private String inspect(HttpServletRequest req, HttpServletResponse resp, boolean isGet) {
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) {
var user = Util.getUser(req); var user = Util.getUser(req);
if (user == null) return redirectTo(LOGIN,resp); if (user == null) return redirectTo(LOGIN,resp);
var data = new HashMap<String,Object>(); var data = new HashMap<String,Object>();
data.put(USER,user);
var error = false; var error = false;
var list = Util.getMailingList(req); var list = Util.getMailingList(req);
@ -399,6 +429,7 @@ public class Web extends TemplateServlet {
error = true; error = true;
data.put(ERROR, t("No valid mailing list provided!")); data.put(ERROR, t("No valid mailing list provided!"));
} else data.put(LIST, list.email()); } else data.put(LIST, list.email());
if (isGet) return loadTemplate(INSPECT,data,resp);
if (!error && !list.mayBeAlteredBy(user)) { if (!error && !list.mayBeAlteredBy(user)) {
error = true; error = true;
@ -451,10 +482,8 @@ public class Web extends TemplateServlet {
} }
} }
private String registerUser(HttpServletRequest req, HttpServletResponse resp, boolean isGet) {
if (isGet) return loadTemplate(REGISTER,null,resp);
private String registerUser(HttpServletRequest req, HttpServletResponse resp) {
var email = req.getParameter("email"); var email = req.getParameter("email");
var pass = req.getParameter("pass"); var pass = req.getParameter("pass");
var pass_repeat = req.getParameter("pass_repeat"); var pass_repeat = req.getParameter("pass_repeat");
@ -463,9 +492,9 @@ public class Web extends TemplateServlet {
if (email == null || email.isBlank() || if (email == null || email.isBlank() ||
name == null || name.isBlank() || name == null || name.isBlank() ||
pass == null || pass.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); 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 (!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); 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; var firstUser = false;
try { try {
@ -474,7 +503,6 @@ public class Web extends TemplateServlet {
return t("Failed to access user database: {}",e.getMessage()); return t("Failed to access user database: {}",e.getMessage());
} }
try { try {
var user = User.create(email, name, pass); var user = User.create(email, name, pass);
if (firstUser) user.addPermission(PERMISSION_ADMIN|User.PERMISSION_CREATE_LISTS); if (firstUser) user.addPermission(PERMISSION_ADMIN|User.PERMISSION_CREATE_LISTS);
@ -486,6 +514,33 @@ 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("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 {}.\n\nIf that was you, please open the following link in your web browser:\n\n{}\n\nIf you did not expect this email, please ignore it.",host,link);
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,"Failed to send reset email: "+e.getMessage()),resp);
}
return null;
}
private String subscribe(HttpServletRequest req, HttpServletResponse resp) { private String subscribe(HttpServletRequest req, HttpServletResponse resp) {
var name = req.getParameter(NAME); var name = req.getParameter(NAME);
var email = req.getParameter(EMAIL); var email = req.getParameter(EMAIL);
@ -517,6 +572,7 @@ public class Web extends TemplateServlet {
int code = cause.getErrorCode(); int code = cause.getErrorCode();
if (code == PRIMARY_KEY_CONSTRAINT) try {// user already exists if (code == PRIMARY_KEY_CONSTRAINT) try {// user already exists
user = User.loadUser(email,pass); user = User.loadUser(email,pass);
req.getSession().setAttribute("user",user);
skipConfirmation = pass != null; // subscription with email address already known to database skipConfirmation = pass != null; // subscription with email address already known to database
// success → subscribe // success → subscribe
} catch (InvalidKeyException | SQLException e) { } catch (InvalidKeyException | SQLException e) {
@ -555,11 +611,9 @@ public class Web extends TemplateServlet {
} }
} }
private String unsubscribe(HttpServletRequest req, HttpServletResponse resp, boolean isGet) {
private String unsubscribe(HttpServletRequest req, HttpServletResponse resp) {
var data = new HashMap<String,Object>(); var data = new HashMap<String,Object>();
var user = getSessionUser(req); var user = Util.getUser(req);
var email = req.getParameter(EMAIL); var email = req.getParameter(EMAIL);
var list = Util.getMailingList(req); var list = Util.getMailingList(req);
data.put(EMAIL,email); data.put(EMAIL,email);
@ -568,6 +622,7 @@ public class Web extends TemplateServlet {
data.put(ERROR,t("No list provided by form data!")); data.put(ERROR,t("No list provided by form data!"));
return loadTemplate(UNSUBSCRIBE,data,resp); return loadTemplate(UNSUBSCRIBE,data,resp);
} else data.put(LIST,list.email()); } else data.put(LIST,list.email());
if (isGet) return loadTemplate(UNSUBSCRIBE,data,resp);
if (user == null) { if (user == null) {
if (email == null || email.isBlank()) { if (email == null || email.isBlank()) {
data.put(ERROR, t("Email is required for list un-subscription!")); data.put(ERROR, t("Email is required for list un-subscription!"));
@ -607,7 +662,6 @@ public class Web extends TemplateServlet {
LOG.warn("Problem during unscubsription of {} from {}:",user.email(),list.email(),e); LOG.warn("Problem during unscubsription of {} from {}:",user.email(),list.email(),e);
data.put(ERROR,"Failed to unsubscribe!"); data.put(ERROR,"Failed to unsubscribe!");
return loadTemplate(UNSUBSCRIBE,data,resp); return loadTemplate(UNSUBSCRIBE,data,resp);
} }
} }
} }

65
src/test/java/de/srsoftware/widerhall/UtilTest.java

@ -4,6 +4,7 @@ import junit.framework.TestCase;
import java.util.Map; import java.util.Map;
import java.util.TreeMap; import java.util.TreeMap;
import static de.srsoftware.widerhall.Util.*;
public class UtilTest extends TestCase { public class UtilTest extends TestCase {
@ -17,17 +18,17 @@ public class UtilTest extends TestCase {
} }
public void testHex(){ public void testHex(){
assertEquals("00",Util.hex(0)); assertEquals("00",hex(0));
assertEquals("09",Util.hex(9)); assertEquals("09",hex(9));
assertEquals("0A",Util.hex(10)); assertEquals("0A",hex(10));
assertEquals("0F",Util.hex(15)); assertEquals("0F",hex(15));
assertEquals("10",Util.hex(16)); assertEquals("10",hex(16));
assertEquals("19",Util.hex(25)); assertEquals("19",hex(25));
assertEquals("FF",Util.hex(255)); assertEquals("FF",hex(255));
} }
public void testSha256() { public void testSha256() {
assertEquals("9F722959A023C02A3BA0FAFDBA81ADED642D6610EFF5DCA32DCE35132E16B6C5",Util.sha256("Dies ist ein Test")); assertEquals("9F722959A023C02A3BA0FAFDBA81ADED642D6610EFF5DCA32DCE35132E16B6C5",sha256("Dies ist ein Test"));
} }
public void testTranslate() { public void testTranslate() {
@ -35,30 +36,40 @@ public class UtilTest extends TestCase {
} }
public void testIsEmail() { public void testIsEmail() {
assertFalse(Util.isEmail("Test")); assertFalse(isEmail("Test"));
assertFalse(Util.isEmail("Test@")); assertFalse(isEmail("Test@"));
assertFalse(Util.isEmail("@Test")); assertFalse(isEmail("@Test"));
assertTrue(Util.isEmail("Test@Domain")); assertTrue(isEmail("Test@Domain"));
assertFalse(Util.isEmail("Test@Domain@Test")); assertFalse(isEmail("Test@Domain@Test"));
} }
public void testSimplePassword() { public void testSimplePassword() {
assertTrue(Util.simplePassword("$@%€#")); // too short assertTrue(simplePassword("$@%€#")); // too short
assertTrue(Util.simplePassword("test123")); // no special chars assertTrue(simplePassword("test123")); // no special chars
assertFalse(Util.simplePassword("test$23")); // contains special chars assertFalse(simplePassword("test$23")); // contains special chars
assertTrue(Util.simplePassword("skgjafdsg")); // chars only assertTrue(simplePassword("skgjafdsg")); // chars only
assertTrue(Util.simplePassword("986535465")); // digits only assertTrue(simplePassword("986535465")); // digits only
assertFalse(Util.simplePassword("test9523")); // mixed digits and chars assertFalse(simplePassword("test9523")); // mixed digits and chars
assertFalse(Util.simplePassword("8986546054")); // digits only, but long enough assertFalse(simplePassword("8986546054")); // digits only, but long enough
assertFalse(Util.simplePassword("salgksdjbw")); // chars only, but long enough assertFalse(simplePassword("salgksdjbw")); // chars only, but long enough
} }
public void testUnsetFlags(){ public void testUnsetFlags(){
assertEquals(0,Util.unset(31,1,2,4,8,16)); assertEquals(0,unset(31,1,2,4,8,16));
assertEquals(1,Util.unset(31,2,4,8,16)); assertEquals(1,unset(31,2,4,8,16));
assertEquals(2,Util.unset(31,1,4,8,16)); assertEquals(2,unset(31,1,4,8,16));
assertEquals(4,Util.unset(31,1,2,8,16)); assertEquals(4,unset(31,1,2,8,16));
assertEquals(8,Util.unset(31,1,2,4,16)); assertEquals(8,unset(31,1,2,4,16));
assertEquals(16,Util.unset(31,1,2,4,8)); 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));
} }
} }

4
src/test/java/de/srsoftware/widerhall/data/ListMemberTest.java

@ -110,7 +110,7 @@ public class ListMemberTest extends TestCase {
}*/ }*/
public void testSafeMap() { 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"); var lm = new ListMember(null,user,ListMember.STATE_AWAITING_CONFIRMATION,"token");
assertEquals(Map.of(EMAIL,"email",NAME,"name",STATE,"awaiting confirmation"),lm.safeMap()); assertEquals(Map.of(EMAIL,"email",NAME,"name",STATE,"awaiting confirmation"),lm.safeMap());
} }
@ -137,7 +137,7 @@ public class ListMemberTest extends TestCase {
}*/ }*/
public void testUser() { 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"); var lm = new ListMember(null,user,ListMember.STATE_AWAITING_CONFIRMATION,"token");
assertEquals(user,lm.user()); assertEquals(user,lm.user());
} }

1
static/templates/login.st

@ -19,5 +19,6 @@
<button type="submit">Login</button> <button type="submit">Login</button>
</fieldset> </fieldset>
</form> </form>
<a href="reset-pw«if(data.email)»?email=«data.email»«endif»">Forgot password?</a>
</body> </body>
</html> </html>

33
static/templates/new_password_form.st

@ -0,0 +1,33 @@
<!DOCTYPE html>
<html>
«head()»
«userinfo()»
<body id="login">
«navigation()»
«messages()»
<h1>Set new account password</h1>
<form method="POST">
<fieldset>
Dear «if(data.user.name)»«data.user.name»«else»«data.user.email»«endif», you may now set a new password for your account «data.email»:
<legend>Login credentials</legend>
<label>
<input enabled="false" value="«data.user.email»" />
Email address
</label>
<label>
<input type="password" name="password" value="" id="password" />
Password
</label>
<label>
<input type="password" name="password-repeat" value="" id="password-repeat" />
Password (repeat)
</label>
<input type="hidden" name="token" value="«data.token»" />
<button type="submit">Save new password</button>
</fieldset>
</form>
<a href="reset-pw">Forgot password?</a>
</body>
</html>

21
static/templates/reset-pw.st

@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
«head()»
<body id="login">
«navigation()»
«messages()»
<h1>Widerhall password recovery</h1>
<form method="POST">
<fieldset>
<legend>Forgot password?</legend>
If you have lost or forgot your password, please enter your email below.
Upon pressing the "Reset password" button, you will be sent an email with a link allowing you to set a new password.
<label>
<input type="text" name="email" value="«data.email»" id="email" />
Email address
</label>
<button type="submit">Reset password</button>
</fieldset>
</form>
</body>
</html>

17
static/templates/reset_link_sent.st

@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
«head()»
<body id="login">
«navigation()»
«messages()»
<h1>Confirmation</h1>
<fieldset>
<legend>
Link sent
</legend>
An email was sent to you:<br/>
This mail contains a link, that proves you are in control about the resprective mail address. Open the received link in a web browser and you will be able to set a new password.
</fieldset>
</body>
</html>

4
static/templates/subscribe.st

@ -11,11 +11,11 @@
<fieldset> <fieldset>
<legend>Suscribe to "«data.list»"</legend> <legend>Suscribe to "«data.list»"</legend>
<label> <label>
<input type="text" name="name" value="«if(data.name)»«data.name»«else»«data.user.name»«endif»"> <input type="text" name="name" value="«if(data.user)»«data.user.name»«else»«data.name»«endif»">
Name Name
</label> </label>
<label> <label>
<input type="text" name="email" value="«if(data.email)»«data.email»«else»«data.user.email»«endif»"> <input type="text" name="email" value="«if(data.user)»«data.user.email»«else»«data.email»«endif»">
Email Email
</label> </label>
<label> <label>

Loading…
Cancel
Save