UserController.java

package access.api;

import access.config.Config;
import access.exception.NotAllowedException;
import access.exception.NotFoundException;
import access.mail.MailBox;
import access.manage.Manage;
import access.model.Authority;
import access.model.EntityType;
import access.model.Institution;
import access.model.Organization;
import access.model.OrganizationMembership;
import access.model.OrganizationStatus;
import access.model.User;
import access.repository.OrganizationRepository;
import access.repository.UserRepository;
import access.security.InstitutionAdmin;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.core.oidc.user.OidcUser;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.servlet.View;
import org.springframework.web.servlet.view.RedirectView;

import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;

import static access.SwaggerOpenIdConfig.API_TOKENS_SCHEME_NAME;
import static access.SwaggerOpenIdConfig.OPEN_ID_SCHEME_NAME;

@RestController
@RequestMapping(value = {"/api/v1/users"}, produces = MediaType.APPLICATION_JSON_VALUE)
@Transactional
@SecurityRequirement(name = OPEN_ID_SCHEME_NAME, scopes = {"openid"})
@EnableConfigurationProperties(Config.class)
@SecurityRequirement(name = API_TOKENS_SCHEME_NAME)
@SuppressWarnings("unchecked")
public class UserController implements UserAccessRights {

    private static final Log LOG = LogFactory.getLog(UserController.class);

    private final Config config;
    private final UserRepository userRepository;
    private final OrganizationRepository organizationRepository;
    private final Manage manage;
    private final MailBox mailBox;
    private final ObjectMapper objectMapper;

    @Autowired
    public UserController(Config config,
                          UserRepository userRepository,
                          OrganizationRepository organizationRepository, Manage manage, MailBox mailBox, ObjectMapper objectMapper) {
        this.config = config;
        this.userRepository = userRepository;
        this.organizationRepository = organizationRepository;
        this.manage = manage;
        this.mailBox = mailBox;
        this.objectMapper = objectMapper;
    }

    @GetMapping("/config")
    public ResponseEntity<Config> config(User user) {
        LOG.debug("/config");
        Config result = new Config(this.config);
        result
                .withAuthenticated(user != null && user.getId() != null)
                .withName(user != null ? user.getName() : null)
                .withStats(manage.stats());
        if (user != null) {
            verifyMissingAttributes(user, result);
        }
        return ResponseEntity.ok(result);
    }

    @GetMapping("/login")
    public View login() {
        LOG.debug("/login");
        return new RedirectView(config.getClientUrl(), false);
    }

    @GetMapping("/me")
    public ResponseEntity<Map<String, Object>> me(@Parameter(hidden = true) User user, Authentication authentication) {
        LOG.debug(String.format("/me for user %s", user.getEduPersonPrincipalName()));

        User userFromDB = userRepository.findDetailsById(user.getId())
                .orElseThrow(() -> new NotFoundException("User not found"));

        String schacHomeOrganization = userFromDB.getSchacHomeOrganization();
        boolean isExternalUserFromSchacHome = schacHomeOrganization.equals(config.getEduIdSchacHomeOrganization());
        userFromDB.setExternalUser(isExternalUserFromSchacHome);
        if (!isExternalUserFromSchacHome) {
            OidcUser oidcUser = (OidcUser) authentication.getPrincipal();
            Map<String, Object> claims = oidcUser.getUserInfo().getClaims();
            String authenticatingAuthority = (String) claims.get("authenticating_authority");
            Map<String, Object> identityProvider = manage.identityProviderByEntityID(authenticatingAuthority);
            String manageIdentifier = (String) identityProvider.get("_id");
            String obtainedAcr = (String) claims.getOrDefault("acr", "urn:oasis:names:tc:SAML:2.0:ac:classes:Password");
            userFromDB.setLoaLevel(this.convertLoaLevel(obtainedAcr));

            // Provision the user into the organization, this is only done once for a user.
            if (userFromDB.getOrganizationMemberships().stream()
                    .noneMatch(organizationMembership -> Objects.equals(organizationMembership.getOrganization()
                            .getManageIdentifier(), manageIdentifier))) {
                Optional<Organization> organizationOptional = organizationRepository.findByManageIdentifier(manageIdentifier);
                Institution institution = (Institution) claims.getOrDefault(InstitutionAdmin.INSTITUTION, null);
                userFromDB.setInstitution(institution);
                Authority authority = userFromDB.isInstitutionAdmin() && institution != null ? Authority.ADMIN : Authority.MEMBER;
                organizationOptional.ifPresentOrElse(
                        organization -> {
                            userFromDB.addOrganizationMembership(new OrganizationMembership(userFromDB, organization, authority));
                        }, () -> {
                            // If no organization is created yet, create it on the fly. This is only done once per organization
                            Institution institutionForOrg = Objects.isNull(institution) ? new Institution(identityProvider) : institution;
                            String organizationName = String.format("%s (%s)", institutionForOrg.getName(), institutionForOrg.getOrganizationName());
                            Integer manageVersion = (Integer) identityProvider.get("version");
                            Organization organization = new Organization(organizationName, schacHomeOrganization, manageIdentifier, manageVersion);
                            //Organizations created based on the IdP's in Manage are approved automatically
                            organization.setStatus(OrganizationStatus.APPROVED);
                            organizationRepository.save(organization);
                            userFromDB.addOrganizationMembership(new OrganizationMembership(userFromDB, organization, authority));
                        }
                );
            }
        }
        userRepository.save(userFromDB);
        Map<String, Object> userMap = objectMapper.convertValue(userFromDB, new TypeReference<>() {
        });
        List<Map<String, Object>> organizationMemberships = (List<Map<String, Object>>) userMap.getOrDefault("organizationMemberships", List.of());
        organizationMemberships.forEach(organizationMembership -> {
            Map<String, Object> organization = (Map<String, Object>) organizationMembership.get("organization");
            String manageIdentifier = (String) organization.get("manageIdentifier");
            if (StringUtils.hasText(manageIdentifier)) {
                Map<String, Object> identityProvider = manage.providerByManageIdentifier(EntityType.saml20_idp, manageIdentifier);
                organization.put("identityProvider", identityProvider);
                List<Map<String, Object>> changeRequests = manage.getChangeRequestsIdentityProvider(identityProvider);
                organization.put("changeRequests", changeRequests);
            }
        });

        return ResponseEntity.ok(userMap);
    }

    @GetMapping("/other/{id}")
    public ResponseEntity<User> details(@PathVariable("id") Long id, @Parameter(hidden = true) User user) {
        LOG.debug(String.format("/other/%s for user %s", id, user.getEduPersonPrincipalName()));

        if (!user.isSuperUser()) {
            throw new NotAllowedException("Not allowed endpoint by " + user.getEmail());
        }
        User other = userRepository.findById(id).orElseThrow(() -> new NotFoundException("User not found"));
        return ResponseEntity.ok(other);
    }

    @DeleteMapping("/{userId}")
    public ResponseEntity<Map<String, Object>> deleteMe(@Parameter(hidden = true) User user, @PathVariable Long userId) {
        LOG.debug(String.format("/delete for user %s", user.getEduPersonPrincipalName()));

        confirmSuperUser(user);

        User userFromDB = userRepository.findDetailsById(userId)
                .orElseThrow(() -> new NotFoundException("User not found"));
        userRepository.delete(userFromDB);

        return Results.deleteResult();
    }

    @GetMapping("/logout")
    public ResponseEntity<Map<String, Object>> logout(HttpServletRequest request) {
        LOG.debug("/logout");
        SecurityContextHolder.clearContext();
        HttpSession session = request.getSession(false);
        if (session != null) {
            session.invalidate();
        }
        return Results.okResult();
    }

    @PostMapping("/feedback")
    public ResponseEntity<Map<String, Object>> feedback(User user, @RequestBody Map<String, String> body) {
        LOG.debug("/feedback from " + user.getEmail());
        String message = body.get("message");
        if (StringUtils.hasText(message)) {
            mailBox.sendFeedbackMail(user, message);
        }
        return Results.okResult();
    }

    @GetMapping("/search")
    public ResponseEntity<Page<Map<String, Object>>> search(@Parameter(hidden = true) User user,
                                                            @RequestParam(value = "query", required = false, defaultValue = "") String query,
                                                            @RequestParam(value = "pageNumber", required = false, defaultValue = "0") int pageNumber,
                                                            @RequestParam(value = "pageSize", required = false, defaultValue = "10") int pageSize,
                                                            @RequestParam(value = "sort", required = false, defaultValue = "name") String sort,
                                                            @RequestParam(value = "sortDirection", required = false, defaultValue = "ASC") String sortDirection) {
        LOG.debug(String.format("/search for user %s", user.getEduPersonPrincipalName()));

        confirmSuperUser(user);

        Pageable pageable = PageRequest.of(pageNumber, pageSize, Sort.by(Sort.Direction.fromString(sortDirection), sort));
        Page<Map<String, Object>> usersPage = StringUtils.hasText(query) ? userRepository.searchByPageWithKeyword(FullSearchQueryParser.parse(query), pageable) :
                userRepository.searchByPage(pageable);
        return ResponseEntity.ok(usersPage);
    }

    private void verifyMissingAttributes(User user, Config result) {
        List<String> missingAttributes = new ArrayList<>();
        if (!StringUtils.hasText(user.getEmail())) {
            missingAttributes.add("email");
        }
        if (!StringUtils.hasText(user.getSchacHomeOrganization())) {
            missingAttributes.add("schacHomeOrganization");
        }
        if (!StringUtils.hasText(user.getFamilyName())) {
            missingAttributes.add("familyName");
        }
        if (!StringUtils.hasText(user.getGivenName())) {
            missingAttributes.add("givenName");
        }
        if (!missingAttributes.isEmpty()) {
            result.withMissingAttributes(missingAttributes);
        }
    }

    private int convertLoaLevel(String acr) {
        if (!StringUtils.hasText(acr) || acr.trim().toLowerCase().endsWith("password")) {
            return 1;
        }
        try {
            int loa = Integer.parseInt(acr.substring(acr.length() - 1));
            //Corner case for 1.5
            return loa == 5 ? 1 : loa;
        } catch (NumberFormatException e) {
            return 1;
        }
    }


}