restructuring, working on password reset email

next steps:
- create reset url and add it to the translation fill map
- implement message translation
- implement otp validation and login
This commit is contained in:
2025-07-08 15:39:48 +02:00
parent 3e91565fb6
commit 7a5bb50ee2
24 changed files with 245 additions and 178 deletions

View File

@@ -33,8 +33,6 @@ public class Constants {
public static final String OIDC_LINK = "oidc_link";
public static final String OIDC_SCOPE = "openid";
public static final String PASS = "pass";
public static final String REDIRECT_URI = "redirect_uri";
public static final String RESPONSE_TYPE = "response_type";
public static final String SCOPE = "scope";

View File

@@ -16,6 +16,7 @@ public class Paths {
public static final String BUTTONS = "buttons";
public static final String OIDC_LOGIN = "oidc_login";
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 WHOAMI = "whoami";

View File

@@ -3,6 +3,7 @@ package de.srsoftware.umbrella.user;
import static de.srsoftware.tools.MimeType.MIME_FORM_URL;
import static de.srsoftware.tools.Optionals.*;
import static de.srsoftware.tools.Strings.uuid;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Paths.LIST;
import static de.srsoftware.umbrella.core.Paths.LOGOUT;
@@ -27,6 +28,11 @@ import de.srsoftware.tools.SessionToken;
import de.srsoftware.umbrella.core.BaseHandler;
import de.srsoftware.umbrella.core.Token;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.core.model.EmailAddress;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import de.srsoftware.umbrella.message.MessageSystem;
import de.srsoftware.umbrella.message.model.Envelope;
import de.srsoftware.umbrella.message.model.Message;
import de.srsoftware.umbrella.user.api.LoginServiceDb;
import de.srsoftware.umbrella.user.api.UserDb;
import de.srsoftware.umbrella.user.model.*;
@@ -47,6 +53,7 @@ import org.json.JSONObject;
public class UserModule extends BaseHandler {
private record State(LoginService loginService, JSONObject config){
public static State of(LoginService loginService, JSONObject config) {
return new State(loginService,config);
@@ -57,7 +64,9 @@ public class UserModule extends BaseHandler {
private static final System.Logger LOG = System.getLogger("User");
private final UserDb users;
private final LoginServiceDb logins;
private final HashMap<String, State> stateMep = new HashMap<>(); // map from state to OIDC provider name
private final HashMap<String, State> stateMap = new HashMap<>(); // map from state to OIDC provider name
private final HashMap<String,String> tokenMap = new HashMap<>();
private final MessageSystem messages;
static {
try {
@@ -67,9 +76,10 @@ public class UserModule extends BaseHandler {
}
}
public UserModule(UserDb userDb, LoginServiceDb loginDb){
users = userDb;
logins = loginDb;
public UserModule(UserDb userDb, LoginServiceDb loginDb, MessageSystem messageSystem){
logins = loginDb;
messages = messageSystem;
users = userDb;
}
@@ -234,6 +244,7 @@ public class UserModule extends BaseHandler {
case OIDC: return postOIDC(ex,path);
case IMPERSONATE: return impersonate(ex,targetId);
case LOGIN: return postLogin(ex);
case RESET_PW: return postResetPassword(ex);
}
return super.doPost(path, ex);
}
@@ -249,7 +260,7 @@ public class UserModule extends BaseHandler {
if (!params.has(CODE)) return sendContent(ex,HTTP_BAD_REQUEST,"missing auth code");
if (!params.has(STATE)) return sendContent(ex,HTTP_BAD_REQUEST,"no state submitted");
var code = params.getString(CODE);
var state = stateMep.remove(params.getString(STATE));
var state = stateMap.remove(params.getString(STATE));
if (state == null) return sendContent(ex,HTTP_BAD_REQUEST,"no state submitted");
var redirect = url(ex).replaceAll("/api/.*","");
var location = state.config.getString(TOKEN_ENDPOINT);
@@ -284,23 +295,6 @@ public class UserModule extends BaseHandler {
}
}
private String verifyAndGetUserId(String jwt, State state) throws UmbrellaException {
var jwksEndpoint = state.config.getString(JWKS_ENDPOINT);
var audience = state.loginService.clientId();
var httpJwks = new HttpsJwks(jwksEndpoint);
var resolver = new HttpsJwksVerificationKeyResolver(httpJwks);
JwtConsumer consumer = new JwtConsumerBuilder()
.setVerificationKeyResolver(resolver)
.setExpectedAudience(audience)
.build();
try {
var claims = consumer.processToClaims(jwt);
return claims.getSubject();
} catch (InvalidJwtException | MalformedClaimException e) {
throw new UmbrellaException(500,"Failed to verify JWT!").causedBy(e);
}
}
private boolean getConnectedServices(HttpExchange ex, UmbrellaUser user) throws IOException {
if (user == null) return sendEmptyResponse(HTTP_UNAUTHORIZED,ex);
try {
@@ -366,7 +360,7 @@ public class UserModule extends BaseHandler {
var authEndpoint = config.getString(AUTH_ENDPOINT);
var clientId = loginService.clientId();
var state = UUID.randomUUID().toString();
stateMep.put(state, State.of(loginService, config));
stateMap.put(state, State.of(loginService, config));
return sendContent(ex,Map.of(OIDC_CALLBACK, callback, AUTH_ENDPOINT, authEndpoint, SCOPE, OIDC_SCOPE, CLIENT_ID, clientId, RESPONSE_TYPE, CODE, STATE, state));
} catch (UmbrellaException e){
return send(ex,e);
@@ -471,6 +465,28 @@ public class UserModule extends BaseHandler {
}
}
private boolean postResetPassword(HttpExchange ex) throws IOException {
try {
var email = body(ex);
var addr = new EmailAddress(email);
var user = users.load(addr);
var token = uuid();
var oldToken = tokenMap.get(email);
if (oldToken != null) tokenMap.remove(oldToken);
tokenMap.put(token,email);
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 message = new Message(user,subject,content,fills,null);
var envelope = new Envelope(message,user);
messages.send(envelope);
} catch (UmbrellaException e){
}
return sendEmptyResponse(HTTP_OK,ex);
}
private boolean patchService(HttpExchange ex, String serviceName, UmbrellaUser requestingUser) throws IOException {
if (!(requestingUser instanceof DbUser user && user.permissions().contains(MANAGE_LOGIN_SERVICES))) return sendEmptyResponse(HTTP_FORBIDDEN,ex);
try {
@@ -513,12 +529,10 @@ public class UserModule extends BaseHandler {
return score;
}
private boolean update(HttpExchange ex, DbUser user, JSONObject json) throws UmbrellaException, IOException {
var id = user.id();
var name = json.has(NAME) && json.get(NAME) instanceof String s && !s.isBlank() ? s : user.name();
var email = json.has(EMAIL) && json.get(EMAIL) instanceof String e && !e.isBlank() ? e : user.email();
var email = json.has(EMAIL) && json.get(EMAIL) instanceof String e && !e.isBlank() ? new EmailAddress(e) : user.email();
var pass = json.has(PASSWORD) && json.get(PASSWORD) instanceof String p && !p.isBlank() ? Password.of(BAD_HASHER.hash(p,null)) : user.hashedPassword();
var theme = json.has(THEME) && json.get(THEME) instanceof String t && !t.isBlank() ? t : user.theme();
var lang = json.has(LANGUAGE) && json.get(LANGUAGE) instanceof String l && !l.isBlank() ? l : user.language();
@@ -526,6 +540,23 @@ public class UserModule extends BaseHandler {
return sendContent(ex,HTTP_OK,saved);
}
private String verifyAndGetUserId(String jwt, State state) throws UmbrellaException {
var jwksEndpoint = state.config.getString(JWKS_ENDPOINT);
var audience = state.loginService.clientId();
var httpJwks = new HttpsJwks(jwksEndpoint);
var resolver = new HttpsJwksVerificationKeyResolver(httpJwks);
JwtConsumer consumer = new JwtConsumerBuilder()
.setVerificationKeyResolver(resolver)
.setExpectedAudience(audience)
.build();
try {
var claims = consumer.processToClaims(jwt);
return claims.getSubject();
} catch (InvalidJwtException | MalformedClaimException e) {
throw new UmbrellaException(500,"Failed to verify JWT!").causedBy(e);
}
}
private static boolean weak(String password){
return score(password) < 14;
};

View File

@@ -3,10 +3,11 @@ package de.srsoftware.umbrella.user.api;
import de.srsoftware.umbrella.core.Token;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.core.model.EmailAddress;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import de.srsoftware.umbrella.user.model.DbUser;
import de.srsoftware.umbrella.user.model.Password;
import de.srsoftware.umbrella.user.model.Session;
import de.srsoftware.umbrella.user.model.UmbrellaUser;
import java.util.List;
public interface UserDb {
@@ -28,6 +29,8 @@ public interface UserDb {
Session load(Token token) throws UmbrellaException;
UmbrellaUser load(EmailAddress email) throws UmbrellaException;
UmbrellaUser load(Long id) throws UmbrellaException;
UmbrellaUser load(Session session) throws UmbrellaException;

View File

@@ -7,6 +7,8 @@ import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION.IMPERSONATE;
import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION.LIST_USERS;
import static de.srsoftware.umbrella.user.model.DbUser.PERMISSION.MANAGE_LOGIN_SERVICES;
import de.srsoftware.umbrella.core.model.EmailAddress;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import java.util.Map;
import java.util.Set;
@@ -27,7 +29,7 @@ public class DbUser extends UmbrellaUser {
private final Password hashedPass;
private final Long lastLogoff;
public DbUser(long id, String name, String email, Password hashedPassword, String theme, String languageCode, Set<PERMISSION> permissions, Long lastLogoff) {
public DbUser(long id, String name, EmailAddress email, Password hashedPassword, String theme, String languageCode, Set<PERMISSION> permissions, Long lastLogoff) {
super(id, name, email, theme, languageCode);
this.hashedPass = hashedPassword;
this.permissions = permissions;

View File

@@ -3,6 +3,7 @@ package de.srsoftware.umbrella.user.model;
import de.srsoftware.tools.SessionToken;
import de.srsoftware.umbrella.core.Token;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import java.time.Instant;
/* © SRSoftware 2025 */

View File

@@ -1,49 +0,0 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.user.model;
import static de.srsoftware.umbrella.core.Constants.*;
import de.srsoftware.tools.Mappable;
import java.util.HashMap;
import java.util.Map;
/* © SRSoftware 2025 */
public class UmbrellaUser extends User implements Mappable {
private final long id;
private final String theme, lang;
public UmbrellaUser(long id, String name, String email, String theme, String languageCode) {
super(name,email);
this.id = id;
this.theme = theme;
this.lang = languageCode;
}
public long id(){
return id;
}
public String language(){
return lang;
}
public String theme(){
return theme;
}
@Override
public Map<String,Object> toMap() {
var map = new HashMap<String,Object>();
map.put(ID,id);
map.put(LOGIN, name()); // this is still used by old umbrella modules
map.put(NAME, name());
map.put(EMAIL,email());
map.put(THEME,theme);
map.put(LANGUAGE,lang);
return map;
}
}

View File

@@ -1,59 +0,0 @@
/* © SRSoftware 2025 */
package de.srsoftware.umbrella.user.model;
import static de.srsoftware.tools.Optionals.nullable;
import static de.srsoftware.umbrella.core.Constants.*;
import static de.srsoftware.umbrella.core.Constants.LANGUAGE;
import static java.text.MessageFormat.format;
import java.util.Objects;
import org.json.JSONObject;
public class User {
private final String email, name;
public User(String name, String email) {
this.name = name;
this.email = email;
}
public String email(){
return email;
}
public String name(){
return name;
}
@Override
public boolean equals(Object o) {
if (!(o instanceof User user)) return false;
return Objects.equals(email, user.email) && Objects.equals(name, user.name);
}
@Override
public int hashCode() {
return Objects.hash(email, name);
}
@Override
public String toString() {
return format("{1}({0})", nullable(name()).orElse(email()),getClass().getSimpleName());
}
public static User of(JSONObject json){
if (json.has(USER)) json = json.getJSONObject(USER);
var name = json.has(NAME) ? json.getString(NAME) : null;
var email = json.has(EMAIL) ? json.getString(EMAIL) : null;
if (json.has(ID) && json.has(THEME)) {
return new UmbrellaUser(
json.getLong(ID),
name,
email,
json.getString(THEME),
json.has(LANGUAGE) ? json.getString(LANGUAGE) : null
);
}
return new User(name,email);
}
}

View File

@@ -14,12 +14,13 @@ import de.srsoftware.tools.PasswordHasher;
import de.srsoftware.tools.jdbc.Query;
import de.srsoftware.umbrella.core.Token;
import de.srsoftware.umbrella.core.UmbrellaException;
import de.srsoftware.umbrella.core.model.EmailAddress;
import de.srsoftware.umbrella.core.model.UmbrellaUser;
import de.srsoftware.umbrella.user.BadHasher;
import de.srsoftware.umbrella.user.api.LoginServiceDb;
import de.srsoftware.umbrella.user.api.UserDb;
import de.srsoftware.umbrella.user.model.*;
import de.srsoftware.umbrella.user.model.Session;
import de.srsoftware.umbrella.user.model.UmbrellaUser;
import java.security.NoSuchAlgorithmException;
import java.sql.Connection;
import java.sql.ResultSet;
@@ -351,6 +352,21 @@ CREATE TABLE IF NOT EXISTS {0} (
return loginService;
}
@Override
public UmbrellaUser load(EmailAddress email) throws UmbrellaException {
if (email == null) throw new UmbrellaException(400,"Email must not be null!");
UmbrellaUser user = null;
try {
var rs = select(ALL).from(TABLE_USERS).where(EMAIL,equal(email.toString())).exec(db);
if (rs.next()) user = toUser(rs);
rs.close();
} catch (SQLException e) {
LOG.log(WARNING,"Failed to load user for \"{0}\"!",email);
}
if (user == null) throw new UmbrellaException(500,"Failed to load user for \"{0}\"!",email);
return user;
}
@Override
public UmbrellaUser load(Long id) throws UmbrellaException {
if (id == null) throw new UmbrellaException(400,"Id must not be null!");
@@ -501,7 +517,7 @@ CREATE TABLE IF NOT EXISTS {0} (
return new DbUser(
id,
rs.getString(LOGIN),
rs.getString(EMAIL),
rs.getString(EMAIL) instanceof String addr && !addr.isBlank() ? new EmailAddress(addr) : null,
Password.of(rs.getString(PASS)),
rs.getString(THEME),
"de", // TODO: save in DB