UserService.java

/*
 * Copyright (c) 2007-2026 MetaSolutions AB
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 *     http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */

package org.entrystore.rest.springboot.service;

import lombok.AccessLevel;
import lombok.RequiredArgsConstructor;
import lombok.Setter;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.Context;
import org.entrystore.ContextManager;
import org.entrystore.Entry;
import org.entrystore.PrincipalManager;
import org.entrystore.User;
import org.entrystore.repository.security.Password;
import org.entrystore.rest.springboot.model.api.GetAuthUserResponse;
import org.entrystore.rest.springboot.model.api.UserSettingsRequestBody;
import org.entrystore.rest.springboot.model.exception.BadRequestException;
import org.entrystore.rest.springboot.model.exception.EntityNotFoundException;
import org.entrystore.rest.springboot.model.exception.ForbiddenException;
import org.entrystore.rest.springboot.service.auth.BasicVerifier;
import org.entrystore.rest.springboot.util.EmailSender;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.ObjectMapper;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.HashMap;
import java.util.List;
import java.util.Locale;
import java.util.Map;

@Slf4j
@Service
@RequiredArgsConstructor
public class UserService {

	private final PrincipalManager principalManager;
	private final ContextManager contextManager;
	private final AuthService authService;
	private final EmailSender emailSender;
	private final ObjectMapper objectMapper;

	@Value("${entrystore.auth.password.require-current-password:true}")
	@Setter(AccessLevel.PACKAGE)
	private boolean requireCurrentPassword;

	public boolean isAdmin(User user) {
		return principalManager.getAdminUser().getURI().equals(user.getURI()) ||
				principalManager.getAdminGroup().isMember(user);
	}

	/**
	 * @param authTokenExpires when the caller's login ends, or null without a session; ignored for the guest
	 */
	public GetAuthUserResponse getUserInfo(String locales, Instant authTokenExpires) {

		User authenticatedUser = principalManager.getUser(principalManager.getAuthenticatedUserURI());

		if (authenticatedUser == null) {
			throw new EntityNotFoundException("The logged-in user " + principalManager.getAuthenticatedUserURI() + " does not exist anymore");
		}

		Map<String, Double> clientAcceptedLanguages = parseLocalesHeader(locales);

		String homeContext = null;
		boolean guest = authenticatedUser.getURI().equals(principalManager.getGuestUser().getURI());
		if (!guest) {
			Context context = authenticatedUser.getHomeContext();
			if (context != null) {
				homeContext = context.getEntry().getId();
			}
		}

		GetAuthUserResponse.GetAuthUserResponseBuilder response = GetAuthUserResponse.builder()
				.id(authenticatedUser.getEntry().getId())
				.homeContext(homeContext)
				.user(authenticatedUser.getName())
				.uri(authenticatedUser.getEntry().getEntryURI().toString())
				.language(authenticatedUser.getLanguage())
				.clientAcceptLanguage(clientAcceptedLanguages)
				.externalId(authenticatedUser.getExternalID());

		if (!guest && authTokenExpires != null) {
			response.authTokenExpires(LocalDateTime.ofInstant(authTokenExpires, ZoneId.systemDefault()));
		}

		return response.build();
	}

	/**
	 * Applies a partial settings update (JSON body, see {@link UserSettingsRequestBody}) to the user behind the
	 * given entry. A password change requires the current password unless an admin changes another user's password
	 * (configurable via {@code entrystore.auth.password.require-current-password}), expires the user's other
	 * sessions ({@code currentSessionId} is spared when users change their own password) and sends a confirmation
	 * email.
	 */
	public void updateSettings(Entry entry, byte[] requestBody, String currentSessionId) {
		UserSettingsRequestBody settings;
		try {
			settings = objectMapper.readValue(requestBody, UserSettingsRequestBody.class);
		} catch (JacksonException e) {
			// Cause preserved for the log; the parser's own message is not echoed to the client.
			throw new BadRequestException(UserSettingsRequestBody.SYNTAX_ERROR_MESSAGE, e);
		}
		if (settings == null) {
			// The JSON literal `null` deserializes to a null reference instead of throwing, so without
			// this the next dereference answers 500 for a four-byte body any authenticated caller can
			// send.
			throw new BadRequestException(UserSettingsRequestBody.SYNTAX_ERROR_MESSAGE);
		}

		User resourceUser = (User) entry.getResource();
		if (settings.hasName()) {
			String newName = settings.nameValue();
			if (!resourceUser.setName(newName)) {
				throw new BadRequestException("Name is already in use: " + newName);
			}
		}
		if (settings.hasPassword()) {
			String newPassword = settings.passwordValue();

			if (requireCurrentPassword) {
				// we require the current password if:
				// (1) the user is a non-admin user, or
				// (2) the user is an admin user and wants to set his own password
				if (!principalManager.currentUserIsAdminOrAdminGroup()
						|| principalManager.getAuthenticatedUserURI().equals(resourceUser.getURI())) {
					if (!settings.hasCurrentPassword()) {
						throw new ForbiddenException("Current password is required");
					}
					String currentPassword = settings.currentPasswordValue();
					String saltedHashedSecret = BasicVerifier.getSaltedHashedSecret(principalManager,
							resourceUser.getName());
					if (saltedHashedSecret == null || !Password.check(currentPassword, saltedHashedSecret)) {
						throw new ForbiddenException("No password set or incorrect current password provided");
					}
				}
			}

			if (resourceUser.setSecret(newPassword)) {
				// we need to expire sessions of the user, whose password is being changed

				// if it is an admin/admingroup member, who is changing the password of another user, we expire all sessions of that user
				// if it is an admin/admingroup member changing his own password, or user changing his own password,
				// we expire all sessions of that admin/user except the session, through which it is being changed (currentSessionId)

				// the test only asks if the authenticatedUser is the same as the user, whose password is to be changed
				// because no user can change password of another user, only admin
				boolean expireAllSessions = !principalManager.getAuthenticatedUserURI().equals(resourceUser.getURI());
				authService.expireUserSessions(resourceUser, expireAllSessions ? null : currentSessionId);

				emailSender.sendPasswordChangeConfirmation(entry);
			} else {
				throw new BadRequestException("Password must conform to configured rules.");
			}
		}
		if (settings.hasLanguage()) {
			String prefLang = settings.languageValue();
			if (prefLang.isEmpty()) {
				resourceUser.setLanguage(null);
			} else if (!resourceUser.setLanguage(prefLang)) {
				throw new BadRequestException("Preferred language could not be set.");
			}
		}
		if (settings.hasHomeContext()) {
			String homeContext = settings.homeContextValue();
			Entry entryHomeContext = contextManager.get(homeContext);
			if (entryHomeContext != null) {
				if (!(entryHomeContext.getResource() instanceof Context)
						|| !resourceUser.setHomeContext((Context) entryHomeContext.getResource())) {

					throw new BadRequestException("Given homecontext is not a context.");
				}
			}
		}
		if (settings.hasDisabled()) {
			if (entry.getResourceURI().equals(principalManager.getAuthenticatedUserURI())) {
				throw new BadRequestException("Users cannot set their own disabled status.");
			}
			resourceUser.setDisabled(settings.disabledValue());
			if (settings.disabledValue()) {
				authService.expireAllSessions(resourceUser);
			}
		}
		if (settings.hasCustomProperties()) {
			resourceUser.setCustomProperties(settings.customPropertiesValue());
		}
	}

	private static Map<String, Double> parseLocalesHeader(String value) {

		Map<String, Double> acceptLanguages = new HashMap<>();

		List<Locale.LanguageRange> ranges = Locale.LanguageRange.parse(value);
		for (Locale.LanguageRange range : ranges) {
			String canonicalTag = Locale.forLanguageTag(range.getRange()).toLanguageTag();
			acceptLanguages.put(canonicalTag, range.getWeight());
		}
		return acceptLanguages;
	}

}