diff --git a/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java b/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java index 5b66b31aee98..0d3280cf5d96 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/repository/UserRepository.java @@ -724,16 +724,6 @@ default Page searchAllWithGroupsByLoginOrNameInCourseAndReturnPage(Pageabl """) void updateUserLanguageKey(@Param("userId") long userId, @Param("languageKey") String languageKey); - @Modifying - @Transactional // ok because of modifying query - @Query(""" - UPDATE User user - SET user.sshPublicKeyHash = :sshPublicKeyHash, - user.sshPublicKey = :sshPublicKey - WHERE user.id = :userId - """) - void updateUserSshPublicKeyHash(@Param("userId") long userId, @Param("sshPublicKeyHash") String sshPublicKeyHash, @Param("sshPublicKey") String sshPublicKey); - @Modifying @Transactional // ok because of modifying query @Query(""" @@ -1120,8 +1110,6 @@ default boolean isCurrentUser(String login) { return SecurityUtils.getCurrentUserLogin().map(currentLogin -> currentLogin.equals(login)).orElse(false); } - Optional findBySshPublicKeyHash(String keyString); - /** * Finds all users which a non-null VCS access token that expires before some given date. * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java index 2a441e4cd035..37a9e088f5f6 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/AccountResource.java @@ -7,8 +7,8 @@ import java.net.URISyntaxException; import java.nio.file.Path; import java.security.GeneralSecurityException; -import java.security.PublicKey; import java.time.ZonedDateTime; +import java.util.List; import java.util.Optional; import jakarta.validation.Valid; @@ -22,6 +22,7 @@ import org.springframework.http.ResponseEntity; 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.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -45,8 +46,10 @@ import de.tum.cit.aet.artemis.core.service.FileService; import de.tum.cit.aet.artemis.core.service.user.UserCreationService; import de.tum.cit.aet.artemis.core.service.user.UserService; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.dto.UserSshPublicKeyDTO; +import de.tum.cit.aet.artemis.programming.service.UserSshPublicKeyService; import de.tum.cit.aet.artemis.programming.service.localvc.LocalVCPersonalAccessTokenManagementService; -import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; /** * REST controller for managing the current user's account. @@ -67,6 +70,8 @@ public class AccountResource { private final UserService userService; + private final UserSshPublicKeyService userSshPublicKeyService; + private final UserCreationService userCreationService; private final AccountService accountService; @@ -75,13 +80,14 @@ public class AccountResource { private static final float MAX_PROFILE_PICTURE_FILESIZE_IN_MEGABYTES = 0.1f; - public AccountResource(UserRepository userRepository, UserService userService, UserCreationService userCreationService, AccountService accountService, - FileService fileService) { + public AccountResource(UserRepository userRepository, UserService userService, UserCreationService userCreationService, AccountService accountService, FileService fileService, + UserSshPublicKeyService userSshPublicKeyService) { this.userRepository = userRepository; this.userService = userService; this.userCreationService = userCreationService; this.accountService = accountService; this.fileService = fileService; + this.userSshPublicKeyService = userSshPublicKeyService; } /** @@ -133,46 +139,87 @@ public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwo } /** - * PUT account/ssh-public-key : sets the ssh public key + * GET account/ssh-public-keys : retrieves all SSH keys of a user * - * @param sshPublicKey the ssh public key to set + * @return the ResponseEntity containing all public SSH keys of a user with status 200 (OK) + */ + @GetMapping("account/ssh-public-keys") + @EnforceAtLeastStudent + public ResponseEntity> getSshPublicKeys() { + User user = userRepository.getUser(); + List keys = userSshPublicKeyService.getAllSshKeysForUser(user); + return ResponseEntity.ok(keys); + } + + /** + * GET account/ssh-public-key : gets the ssh public key * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + * @param keyId The id of the key that should be fetched + * + * @return the ResponseEntity containing the requested public SSH key of a user with status 200 (OK), or with status 403 (Access Forbidden) if the key does not exist or is not + * owned by the requesting user + */ + @GetMapping("account/ssh-public-key/{keyId}") + @EnforceAtLeastStudent + public ResponseEntity getSshPublicKey(@PathVariable Long keyId) { + User user = userRepository.getUser(); + UserSshPublicKey key = userSshPublicKeyService.getSshKeyForUser(user, keyId); + return ResponseEntity.ok(UserSshPublicKeyDTO.of(key)); + } + + /** + * GET account/has-ssh-public-key : gets the ssh public key + * + * @return the ResponseEntity containing true if the User has SSH keys, and false if it does not, with status 200 (OK) */ - @PutMapping("account/ssh-public-key") + @GetMapping("account/has-ssh-public-keys") @EnforceAtLeastStudent - public ResponseEntity addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException { + public ResponseEntity hasUserSSHkeys() { + User user = userRepository.getUser(); + boolean hasKeys = userSshPublicKeyService.hasUserSSHkeys(user.getId()); + return ResponseEntity.ok(hasKeys); + } + /** + * POST account/ssh-public-key : creates a new ssh public key for a user + * + * @param sshPublicKey the ssh public key to create + * + * @return the ResponseEntity with status 200 (OK), or with status 400 (Bad Request) when the SSH key is malformed, the label is too long, or when a key with the same hash + * already exists + */ + @PostMapping("account/ssh-public-key") + @EnforceAtLeastStudent + public ResponseEntity addSshPublicKey(@RequestBody UserSshPublicKeyDTO sshPublicKey) throws GeneralSecurityException, IOException { User user = userRepository.getUser(); log.debug("REST request to add SSH key to user {}", user.getLogin()); - // Parse the public key string AuthorizedKeyEntry keyEntry; try { - keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey); + keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey.publicKey()); } catch (IllegalArgumentException e) { throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); } - // Extract the PublicKey object - PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); - String keyHash = HashUtils.getSha512Fingerprint(publicKey); - userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey); + + userSshPublicKeyService.createSshKeyForUser(user, keyEntry, sshPublicKey); return ResponseEntity.ok().build(); } /** - * PUT account/ssh-public-key : sets the ssh public key + * Delete - account/ssh-public-key : deletes the ssh public key by its keyId * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) + * @param keyId The id of the key that should be deleted + * + * @return the ResponseEntity with status 200 (OK) when the deletion succeeded, or with status 403 (Access Forbidden) if the key does not belong to the user, or does not exist */ - @DeleteMapping("account/ssh-public-key") + @DeleteMapping("account/ssh-public-key/{keyId}") @EnforceAtLeastStudent - public ResponseEntity deleteSshPublicKey() { + public ResponseEntity deleteSshPublicKey(@PathVariable Long keyId) { User user = userRepository.getUser(); log.debug("REST request to remove SSH key of user {}", user.getLogin()); - userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); + userSshPublicKeyService.deleteUserSshPublicKey(user.getId(), keyId); - log.debug("Successfully deleted SSH key of user {}", user.getLogin()); + log.debug("Successfully deleted SSH key with id {} of user {}", keyId, user.getLogin()); return ResponseEntity.ok().build(); } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java b/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java new file mode 100644 index 000000000000..8348c7eb656c --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/domain/UserSshPublicKey.java @@ -0,0 +1,131 @@ +package de.tum.cit.aet.artemis.programming.domain; + +import java.time.ZonedDateTime; + +import jakarta.annotation.Nullable; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import org.hibernate.annotations.Cache; +import org.hibernate.annotations.CacheConcurrencyStrategy; + +import de.tum.cit.aet.artemis.core.domain.DomainObject; + +/** + * A public SSH key of a user. + */ +@Entity +@Table(name = "user_public_ssh_key") +@Cache(usage = CacheConcurrencyStrategy.NONSTRICT_READ_WRITE) +public class UserSshPublicKey extends DomainObject { + + /** + * The user who is owner of the public key + */ + @NotNull + @Column(name = "user_id") + private long userId; + + /** + * The label of the SSH key shwon in the UI + */ + @Size(max = 50) + @Column(name = "label", length = 50) + private String label; + + /** + * The actual full public ssh key of a user used to authenticate git clone and git push operations if available + */ + @NotNull + @Column(name = "public_key") + private String publicKey; + + /** + * A hash of the public ssh key for fast comparison in the database (with an index) + */ + @Size(max = 100) + @Column(name = "key_hash") + private String keyHash; + + /** + * The creation date of the public SSH key + */ + @Column(name = "creation_date") + private ZonedDateTime creationDate = null; + + /** + * The last used date of the public SSH key + */ + @Nullable + @Column(name = "last_used_date") + private ZonedDateTime lastUsedDate = null; + + /** + * The expiry date of the public SSH key + */ + @Nullable + @Column(name = "expiry_date") + private ZonedDateTime expiryDate = null; + + public @NotNull long getUserId() { + return userId; + } + + public void setUserId(@NotNull long userId) { + this.userId = userId; + } + + public @Size(max = 50) String getLabel() { + return label; + } + + public void setLabel(@Size(max = 50) String label) { + this.label = label; + } + + public String getPublicKey() { + return publicKey; + } + + public void setPublicKey(String publicKey) { + this.publicKey = publicKey; + } + + @Nullable + public @Size(max = 100) String getKeyHash() { + return keyHash; + } + + public void setKeyHash(@Nullable @Size(max = 100) String keyHash) { + this.keyHash = keyHash; + } + + public ZonedDateTime getCreationDate() { + return creationDate; + } + + public void setCreationDate(ZonedDateTime creationDate) { + this.creationDate = creationDate; + } + + @Nullable + public ZonedDateTime getLastUsedDate() { + return lastUsedDate; + } + + public void setLastUsedDate(@Nullable ZonedDateTime lastUsedDate) { + this.lastUsedDate = lastUsedDate; + } + + @Nullable + public ZonedDateTime getExpiryDate() { + return expiryDate; + } + + public void setExpiryDate(@Nullable ZonedDateTime expiryDate) { + this.expiryDate = expiryDate; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java b/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java new file mode 100644 index 000000000000..ecfeedab8126 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/dto/UserSshPublicKeyDTO.java @@ -0,0 +1,16 @@ +package de.tum.cit.aet.artemis.programming.dto; + +import java.time.ZonedDateTime; + +import com.fasterxml.jackson.annotation.JsonInclude; + +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; + +@JsonInclude(JsonInclude.Include.NON_EMPTY) +public record UserSshPublicKeyDTO(Long id, String label, String publicKey, String keyHash, ZonedDateTime creationDate, ZonedDateTime lastUsedDate, ZonedDateTime expiryDate) { + + public static UserSshPublicKeyDTO of(UserSshPublicKey userSshPublicKey) { + return new UserSshPublicKeyDTO(userSshPublicKey.getId(), userSshPublicKey.getLabel(), userSshPublicKey.getPublicKey(), userSshPublicKey.getKeyHash(), + userSshPublicKey.getCreationDate(), userSshPublicKey.getLastUsedDate(), userSshPublicKey.getExpiryDate()); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java b/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java new file mode 100644 index 000000000000..b177b7b72089 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/repository/UserSshPublicKeyRepository.java @@ -0,0 +1,27 @@ +package de.tum.cit.aet.artemis.programming.repository; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.util.List; +import java.util.Optional; + +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Repository; + +import de.tum.cit.aet.artemis.core.repository.base.ArtemisJpaRepository; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; + +@Profile(PROFILE_CORE) +@Repository +public interface UserSshPublicKeyRepository extends ArtemisJpaRepository { + + List findAllByUserId(Long userId); + + Optional findByKeyHash(String keyHash); + + Optional findByIdAndUserId(Long keyId, Long userId); + + boolean existsByIdAndUserId(Long id, Long userId); + + boolean existsByUserId(Long userId); +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java new file mode 100644 index 000000000000..4212cac6c4ce --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java @@ -0,0 +1,141 @@ +package de.tum.cit.aet.artemis.programming.service; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.security.PublicKey; +import java.time.ZonedDateTime; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import org.apache.commons.lang3.StringUtils; +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.AccessForbiddenException; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.dto.UserSshPublicKeyDTO; +import de.tum.cit.aet.artemis.programming.repository.UserSshPublicKeyRepository; +import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; + +@Profile(PROFILE_CORE) +@Service +public class UserSshPublicKeyService { + + private static final String KEY_DEFAULT_LABEL = "Key 1"; + + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + public UserSshPublicKeyService(UserSshPublicKeyRepository userSshPublicKeyRepository) { + this.userSshPublicKeyRepository = userSshPublicKeyRepository; + } + + /** + * Creates a new SSH public key for the specified user, ensuring that the key is unique + * based on its SHA-512 hash fingerprint. If the key already exists, an exception is thrown. + * + * @param user the {@link User} for whom the SSH key is being created. + * @param keyEntry the {@link AuthorizedKeyEntry} containing the SSH public key details, used to resolve the {@link PublicKey}. + * @param sshPublicKey the {@link UserSshPublicKey} object containing metadata about the SSH key such as the key itself, label, and expiry date. + */ + public void createSshKeyForUser(User user, AuthorizedKeyEntry keyEntry, UserSshPublicKeyDTO sshPublicKey) throws GeneralSecurityException, IOException { + PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null); + String keyHash = HashUtils.getSha512Fingerprint(publicKey); + + if (userSshPublicKeyRepository.findByKeyHash(keyHash).isPresent()) { + throw new BadRequestAlertException("Key already exists", "SSH key", "keyAlreadyExists", true); + } + + UserSshPublicKey newUserSshPublicKey = new UserSshPublicKey(); + newUserSshPublicKey.setUserId(user.getId()); + newUserSshPublicKey.setPublicKey(sshPublicKey.publicKey()); + newUserSshPublicKey.setKeyHash(keyHash); + setLabelForKey(newUserSshPublicKey, sshPublicKey.label()); + newUserSshPublicKey.setCreationDate(ZonedDateTime.now()); + newUserSshPublicKey.setExpiryDate(sshPublicKey.expiryDate()); + userSshPublicKeyRepository.save(newUserSshPublicKey); + } + + /** + * Sets the label for the provided SSH public key. If the given label is null or empty, + * the label is extracted from the public key or defaults to a predefined value. + * + * @param newSshPublicKey the {@link UserSshPublicKey} for which the label is being set. + * @param label the label to assign to the SSH key, or null/empty to use the default logic. + * @throws BadRequestAlertException if the key label is longer than 50 characters + */ + private void setLabelForKey(UserSshPublicKey newSshPublicKey, String label) { + if (StringUtils.isBlank(label)) { + String[] parts = newSshPublicKey.getPublicKey().split("\\s+"); + + // we are only interested in the comment of the key. A typical key looks like this, the key prefix, the actual key and then the comment: + // ssh-rsa AAAAB3NzaC1yc2EAAAADAYVTLQ== comment + if (parts.length >= 3) { + label = String.join(" ", Arrays.copyOfRange(parts, 2, parts.length)); + } + else { + label = KEY_DEFAULT_LABEL; + } + } + if (label.length() <= 50) { + newSshPublicKey.setLabel(label); + } + else { + throw new BadRequestAlertException("Key label is too long", "SSH key", "keyLabelTooLong", true); + } + } + + /** + * Retrieves the SSH public key for the specified user by key ID. + * + * @param user the {@link User} to whom the SSH key belongs. + * @param keyId the ID of the SSH key. + * @return the {@link UserSshPublicKey} if found and belongs to the user. + * @throws AccessForbiddenException if the key does not belong to the user, or does not exist + */ + public UserSshPublicKey getSshKeyForUser(User user, Long keyId) { + Optional userSshPublicKey = userSshPublicKeyRepository.findByIdAndUserId(keyId, user.getId()); + return userSshPublicKey.orElseThrow(() -> new AccessForbiddenException("SSH key", keyId)); + } + + /** + * Retrieves all SSH public keys associated with the specified user. + * + * @param user the {@link User} whose SSH keys are to be retrieved. + * @return a list of {@link UserSshPublicKey} objects for the user. + */ + public List getAllSshKeysForUser(User user) { + return userSshPublicKeyRepository.findAllByUserId(user.getId()).stream().map(UserSshPublicKeyDTO::of).toList(); + } + + /** + * Deletes the specified SSH public key for the given user ID. + * + * @param userId the ID of the user. + * @param keyId the ID of the SSH key to delete. + * @throws AccessForbiddenException if the key does not belong to the user. + */ + public void deleteUserSshPublicKey(Long userId, Long keyId) { + if (userSshPublicKeyRepository.existsByIdAndUserId(keyId, userId)) { + userSshPublicKeyRepository.deleteById(keyId); + } + else { + throw new AccessForbiddenException("SSH key", keyId); + } + } + + /** + * Returns whether the user of the specified id has stored SSH keys + * + * @param userId the ID of the user. + * @return true if the user has SSH keys, false if not + */ + public boolean hasUserSSHkeys(Long userId) { + return userSshPublicKeyRepository.existsByUserId(userId); + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java index 4f0cad0aa4e9..ea2f735dad05 100644 --- a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/GitPublickeyAuthenticatorService.java @@ -5,6 +5,7 @@ import java.io.IOException; import java.security.GeneralSecurityException; import java.security.PublicKey; +import java.time.ZonedDateTime; import java.util.Objects; import java.util.Optional; @@ -18,6 +19,8 @@ import de.tum.cit.aet.artemis.buildagent.dto.BuildAgentInformation; import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey; +import de.tum.cit.aet.artemis.programming.repository.UserSshPublicKeyRepository; import de.tum.cit.aet.artemis.programming.service.localci.SharedQueueManagementService; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshConstants; @@ -32,41 +35,76 @@ public class GitPublickeyAuthenticatorService implements PublickeyAuthenticator private final Optional localCIBuildJobQueueService; - public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService) { + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService, + UserSshPublicKeyRepository userSshPublicKeyRepository) { this.userRepository = userRepository; this.localCIBuildJobQueueService = localCIBuildJobQueueService; + this.userSshPublicKeyRepository = userSshPublicKeyRepository; } @Override public boolean authenticate(String username, PublicKey publicKey, ServerSession session) { String keyHash = HashUtils.getSha512Fingerprint(publicKey); - var user = userRepository.findBySshPublicKeyHash(keyHash); - if (user.isPresent()) { - try { - // Retrieve the stored public key string - String storedPublicKeyString = user.get().getSshPublicKey(); - - // Parse the stored public key string - AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedPublicKeyString); - PublicKey storedPublicKey = keyEntry.resolvePublicKey(null, null, null); - - // Compare the stored public key with the provided public key - if (Objects.equals(storedPublicKey, publicKey)) { - log.debug("Found user {} for public key authentication", user.get().getLogin()); - session.setAttribute(SshConstants.USER_KEY, user.get()); - session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); - return true; - } - else { - log.warn("Public key mismatch for user {}", user.get().getLogin()); - } + var userSshPublicKey = userSshPublicKeyRepository.findByKeyHash(keyHash); + return userSshPublicKey.map(sshPublicKey -> { + ZonedDateTime expiryDate = sshPublicKey.getExpiryDate(); + if (expiryDate == null || expiryDate.isAfter(ZonedDateTime.now())) { + return authenticateUser(sshPublicKey, publicKey, session); } - catch (Exception e) { - log.error("Failed to convert stored public key string to PublicKey object", e); + return false; + }).orElseGet(() -> authenticateBuildAgent(publicKey, session)); + } + + /** + * Tries to authenticate a user by the provided key + * + * @param storedKey The key stored in the Artemis database + * @param providedKey The key provided by the user for authentication + * @param session The SSH server session + * + * @return true if the authentication succeeds, and false if it doesn't + */ + private boolean authenticateUser(UserSshPublicKey storedKey, PublicKey providedKey, ServerSession session) { + try { + var user = userRepository.findById(storedKey.getUserId()); + if (user.isEmpty()) { + return false; + } + // Retrieve and parse the stored public key string + AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedKey.getPublicKey()); + PublicKey storedPublicKey = keyEntry.resolvePublicKey(null, null, null); + + // Compare the stored public key with the provided public key + if (Objects.equals(storedPublicKey, providedKey)) { + log.debug("Found user {} for public key authentication", user.get().getLogin()); + session.setAttribute(SshConstants.USER_KEY, user.get()); + session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, false); + return true; } + else { + log.warn("Public key mismatch for user {}", user.get().getLogin()); + } + } + catch (Exception e) { + log.error("Failed to convert stored public key string to PublicKey object", e); } - else if (localCIBuildJobQueueService.isPresent() - && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, publicKey))) { + return false; + } + + /** + * Tries to authenticate a build agent by the provided key + * + * @param providedKey The key provided by the user for authentication + * @param session The SSH server session + * + * @return true if the authentication succeeds, and false if it doesn't + */ + private boolean authenticateBuildAgent(PublicKey providedKey, ServerSession session) { + if (localCIBuildJobQueueService.isPresent() + && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, providedKey))) { + log.info("Authenticating as build agent"); session.setAttribute(SshConstants.IS_BUILD_AGENT_KEY, true); return true; @@ -74,6 +112,14 @@ else if (localCIBuildJobQueueService.isPresent() return false; } + /** + * Checks whether a provided key matches the build agents public key + * + * @param agent The build agent which tires to be authenticated by Artemis + * @param publicKey The provided public key + * + * @return true if the build agents has this public key, and false if it doesn't + */ private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation agent, PublicKey publicKey) { if (agent.publicSshKey() == null) { return false; diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshFingerprintsProviderService.java b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshFingerprintsProviderService.java new file mode 100644 index 000000000000..5d1db5b15e73 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/localvc/ssh/SshFingerprintsProviderService.java @@ -0,0 +1,50 @@ +package de.tum.cit.aet.artemis.programming.service.localvc.ssh; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; + +import java.io.IOException; +import java.security.GeneralSecurityException; +import java.util.HashMap; +import java.util.Map; + +import jakarta.ws.rs.BadRequestException; + +import org.apache.sshd.common.keyprovider.KeyPairProvider; +import org.apache.sshd.server.SshServer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Service; + +/** + * This class configures the JGit Servlet, which is used to receive Git push and fetch requests for local VC. + */ +@Profile(PROFILE_LOCALVC) +@Service +public class SshFingerprintsProviderService { + + private static final Logger log = LoggerFactory.getLogger(SshFingerprintsProviderService.class); + + private final SshServer sshServer; + + public SshFingerprintsProviderService(SshServer sshServer) { + this.sshServer = sshServer; + } + + public Map getSshFingerPrints() { + Map fingerprints = new HashMap<>(); + KeyPairProvider keyPairProvider = sshServer.getKeyPairProvider(); + if (keyPairProvider != null) { + try { + keyPairProvider.loadKeys(null).iterator() + .forEachRemaining(keyPair -> fingerprints.put(keyPair.getPublic().getAlgorithm(), HashUtils.getSha512Fingerprint(keyPair.getPublic()))); + + } + catch (IOException | GeneralSecurityException e) { + log.info("Could not load keys from the ssh server while trying to get SSH key fingerprints", e); + throw new BadRequestException("Could not load keys from the ssh server"); + } + } + return fingerprints; + } +} diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshFingerprintsProviderResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshFingerprintsProviderResource.java new file mode 100644 index 000000000000..eaaf3d345fb7 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshFingerprintsProviderResource.java @@ -0,0 +1,44 @@ +package de.tum.cit.aet.artemis.programming.web.localvc.ssh; + +import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_LOCALVC; + +import java.util.Map; + +import org.springframework.context.annotation.Profile; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +import de.tum.cit.aet.artemis.core.service.feature.Feature; +import de.tum.cit.aet.artemis.core.service.feature.FeatureToggle; +import de.tum.cit.aet.artemis.programming.service.localvc.ssh.SshFingerprintsProviderService; + +/** + * REST controller for managing. + */ +@Profile(PROFILE_LOCALVC) +@RestController +@RequestMapping("api/") +public class SshFingerprintsProviderResource { + + SshFingerprintsProviderService sshFingerprintsProviderService; + + public SshFingerprintsProviderResource(SshFingerprintsProviderService sshFingerprintsProviderService) { + this.sshFingerprintsProviderService = sshFingerprintsProviderService; + } + + /** + * GET /ssh-fingerprints + * + * @return the SSH fingerprints for the keys a user uses + */ + @GetMapping(value = "ssh-fingerprints", produces = MediaType.APPLICATION_JSON_VALUE) + @EnforceAtLeastStudent + @FeatureToggle(Feature.Exports) + public ResponseEntity> getSshFingerprints() { + return ResponseEntity.ok().body(sshFingerprintsProviderService.getSshFingerPrints()); + } +} diff --git a/src/main/resources/config/liquibase/changelog/20241013150000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241013150000_changelog.xml new file mode 100644 index 000000000000..7627a0188f06 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241013150000_changelog.xml @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + INSERT INTO user_public_ssh_key (user_id, label, public_key, key_hash, creation_date, last_used_date, expiry_date) + SELECT id, 'Key 1', ssh_public_key, ssh_public_key_hash, CURRENT_TIMESTAMP, NULL, NULL + FROM jhi_user + WHERE ssh_public_key IS NOT NULL; + + + + \ No newline at end of file diff --git a/src/main/resources/config/liquibase/master.xml b/src/main/resources/config/liquibase/master.xml index 5ae5903ea896..eac4d911931a 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -28,8 +28,9 @@ - + + diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index a89944954c05..5307f9a91d5a 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -13,6 +13,7 @@ import { Exercise, getCourseFromExercise } from 'app/entities/exercise.model'; import { Authority } from 'app/shared/constants/authority.constants'; import { TranslateService } from '@ngx-translate/core'; import { EntityResponseType } from 'app/complaints/complaint.service'; +import { UserSshPublicKey } from 'app/entities/programming/user-ssh-public-key.model'; export interface IAccountService { save: (account: any) => Observable>; @@ -30,7 +31,7 @@ export interface IAccountService { isAuthenticated: () => boolean; getAuthenticationState: () => Observable; getImageUrl: () => string | undefined; - addSshPublicKey: (sshPublicKey: string) => Observable; + addNewSshPublicKey: (userSshPublicKey: UserSshPublicKey) => Observable>; } @Injectable({ providedIn: 'root' }) @@ -328,23 +329,38 @@ export class AccountService implements IAccountService { /** * Sends the added SSH key to the server * - * @param sshPublicKey + * @param userSshPublicKey The userSshPublicKey DTO containing the details for the new key which should be created */ - addSshPublicKey(sshPublicKey: string): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = sshPublicKey; - } - return this.http.put('api/account/ssh-public-key', sshPublicKey); + addNewSshPublicKey(userSshPublicKey: UserSshPublicKey): Observable> { + return this.http.post('api/account/ssh-public-key', userSshPublicKey, { observe: 'response' }); + } + + /** + * Retrieves all public SSH keys of a user + */ + getAllSshPublicKeys(): Observable { + return this.http.get('api/account/ssh-public-keys'); + } + + /** + * Checks if a user has SSH key stored in the database + */ + hasUserSshPublicKeys(): Observable { + return this.http.get('api/account/has-ssh-public-keys'); + } + + /** + * Retrieves a specific public SSH keys of a user + */ + getSshPublicKey(keyId: number): Observable { + return this.http.get(`api/account/ssh-public-key/${keyId}`); } /** * Sends a request to the server to delete the user's current SSH key */ - deleteSshPublicKey(): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = undefined; - } - return this.http.delete('api/account/ssh-public-key'); + deleteSshPublicKey(keyId: number): Observable { + return this.http.delete(`api/account/ssh-public-key/${keyId}`); } /** diff --git a/src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts b/src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts new file mode 100644 index 000000000000..4d53e9d2cef5 --- /dev/null +++ b/src/main/webapp/app/entities/programming/user-ssh-public-key.model.ts @@ -0,0 +1,13 @@ +import { BaseEntity } from 'app/shared/model/base-entity'; +import dayjs from 'dayjs/esm'; + +export class UserSshPublicKey implements BaseEntity { + id: number; + label: string; + publicKey: string; + keyHash: string; + expiryDate?: dayjs.Dayjs; + lastUsedDate?: dayjs.Dayjs; + creationDate: dayjs.Dayjs; + hasExpired?: boolean; +} diff --git a/src/main/webapp/app/shared/components/code-button/code-button.component.ts b/src/main/webapp/app/shared/components/code-button/code-button.component.ts index 5d753b661a44..c823449f95da 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.ts +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.ts @@ -49,6 +49,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { gitlabVCEnabled = false; showCloneUrlWithoutToken = true; copyEnabled? = true; + doesUserHaveSSHkeys = false; sshKeyMissingTip: string; tokenMissingTip: string; @@ -79,27 +80,29 @@ export class CodeButtonComponent implements OnInit, OnChanges { private ideSettingsService: IdeSettingsService, ) {} - ngOnInit() { - this.accountService - .identity() - .then((user) => { - this.user = user!; - this.refreshTokenState(); + async ngOnInit() { + this.user = (await this.accountService.identity())!; + this.refreshTokenState(); + + this.copyEnabled = true; + this.useSsh = this.localStorage.retrieve('useSsh') || false; + this.useToken = this.localStorage.retrieve('useToken') || false; + this.localStorage.observe('useSsh').subscribe((useSsh) => (this.useSsh = useSsh || false)); + this.localStorage.observe('useToken').subscribe((useToken) => (this.useToken = useToken || false)); - this.copyEnabled = true; - this.useSsh = this.localStorage.retrieve('useSsh') || false; - this.useToken = this.localStorage.retrieve('useToken') || false; - this.localStorage.observe('useSsh').subscribe((useSsh) => (this.useSsh = useSsh || false)); - this.localStorage.observe('useToken').subscribe((useToken) => (this.useToken = useToken || false)); + if (this.useToken) { + this.useHttpsUrlWithToken(); + } + this.loadParticipationVcsAccessTokens(); + this.accountService.hasUserSshPublicKeys().subscribe({ + next: (res: boolean) => { + this.doesUserHaveSSHkeys = res; if (this.useSsh) { this.useSshUrl(); } - if (this.useToken) { - this.useHttpsUrlWithToken(); - } - }) - .then(() => this.loadParticipationVcsAccessTokens()); + }, + }); // Get ssh information from the user this.profileService.getProfileInfo().subscribe((profileInfo) => { @@ -152,7 +155,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { public useSshUrl() { this.useSsh = true; this.useToken = false; - this.copyEnabled = this.useSsh && (!!this.user.sshPublicKey || this.gitlabVCEnabled); + this.copyEnabled = this.doesUserHaveSSHkeys || this.gitlabVCEnabled; this.storeToLocalStorage(); } diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html new file mode 100644 index 000000000000..d1ecbd74bbc3 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.html @@ -0,0 +1,138 @@ +@if (isLoading) { +

+} @else { + @if (!isCreateMode) { +

+ } @else { +

+ } + +
+ +
+
+

+ + +

+
+ + @if (isCreateMode) { +
+

+
+ } @else { +
+

{{ displayedKeyLabel }}

+
+ } +
+ +
+ + + @if (isCreateMode) { +
+

+ + {{ copyInstructions }} +

+
+ +
+

+ +

+ +

+
+ + +
+ +

+
+
+ +
+
+ +
+ + + @if (selectedOption === 'useExpiration') { +
+
+ +
+
+ } + } @else { + + @if (displayCreationDate) { +
+
+
+ {{ displayCreationDate | artemisDate: 'long-date' }} +
+
+ } + @if (displayedLastUsedDate) { +
+
+
+ {{ displayedLastUsedDate | artemisDate: 'long-date' }} +
+
+ } + @if (displayedExpiryDate) { +
+
+
+ {{ displayedExpiryDate | artemisDate: 'long-date' }} +
+
+ } + } +
+ @if (isCreateMode) { +
+ +
+ } +
+ +
+
+
+
+} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts new file mode 100644 index 000000000000..fe12375d324e --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts @@ -0,0 +1,145 @@ +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +import { AccountService } from 'app/core/auth/account.service'; +import { Subject, Subscription, concatMap, filter, tap } from 'rxjs'; +import { ActivatedRoute, Router } from '@angular/router'; +import { faEdit, faSave } from '@fortawesome/free-solid-svg-icons'; +import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { AlertService } from 'app/core/util/alert.service'; +import { getOS } from 'app/shared/util/os-detector.util'; +import { UserSshPublicKey } from 'app/entities/programming/user-ssh-public-key.model'; +import dayjs from 'dayjs/esm'; + +@Component({ + selector: 'jhi-account-information', + templateUrl: './ssh-user-settings-key-details.component.html', + styleUrls: ['../../user-settings.scss', '../ssh-user-settings.component.scss'], +}) +export class SshUserSettingsKeyDetailsComponent implements OnInit, OnDestroy { + readonly accountService = inject(AccountService); + readonly route = inject(ActivatedRoute); + readonly router = inject(Router); + readonly alertService = inject(AlertService); + + readonly documentationType: DocumentationType = 'SshSetup'; + readonly invalidKeyFormat = 'invalidKeyFormat'; + readonly keyAlreadyExists = 'keyAlreadyExists'; + readonly keyLabelTooLong = 'keyLabelTooLong'; + + protected readonly faEdit = faEdit; + protected readonly faSave = faSave; + protected readonly ButtonType = ButtonType; + protected readonly ButtonSize = ButtonSize; + + subscription: Subscription; + + // state change variables + isCreateMode = false; // true when creating new key, false when viewing existing key + isLoading = true; + + copyInstructions = ''; + selectedOption: string = 'doNotUseExpiration'; + + // Key details from input fields + displayedKeyLabel = ''; + displayedSshKey = ''; + displayedKeyHash = ''; + displayedExpiryDate?: dayjs.Dayjs; + isExpiryDateValid = false; + displayCreationDate: dayjs.Dayjs; + displayedLastUsedDate?: dayjs.Dayjs; + currentDate: dayjs.Dayjs; + + private dialogErrorSource = new Subject(); + dialogError$ = this.dialogErrorSource.asObservable(); + + ngOnInit() { + this.setMessageBasedOnOS(getOS()); + this.currentDate = dayjs(); + + this.subscription = this.route.params + .pipe( + filter((params) => { + const keyId = Number(params['keyId']); + if (keyId) { + this.isCreateMode = false; + return true; + } else { + this.isLoading = false; + this.isCreateMode = true; + return false; + } + }), + concatMap((params) => { + return this.accountService.getSshPublicKey(Number(params['keyId'])); + }), + tap((publicKey: UserSshPublicKey) => { + this.displayedSshKey = publicKey.publicKey; + this.displayedKeyLabel = publicKey.label; + this.displayedKeyHash = publicKey.keyHash; + this.displayCreationDate = publicKey.creationDate; + this.displayedExpiryDate = publicKey.expiryDate; + this.displayedLastUsedDate = publicKey.lastUsedDate; + this.isLoading = false; + }), + ) + .subscribe(); + } + + ngOnDestroy() { + this.subscription.unsubscribe(); + } + + saveSshKey() { + const newUserSshKey = { + label: this.displayedKeyLabel, + publicKey: this.displayedSshKey, + expiryDate: this.displayedExpiryDate, + } as UserSshPublicKey; + this.accountService.addNewSshPublicKey(newUserSshKey).subscribe({ + next: () => { + this.alertService.success('artemisApp.userSettings.sshSettingsPage.saveSuccess'); + this.goBack(); + }, + error: (error) => { + const errorKey = error.error.errorKey; + if ([this.invalidKeyFormat, this.keyAlreadyExists, this.keyLabelTooLong].indexOf(errorKey) > -1) { + this.alertService.error(`artemisApp.userSettings.sshSettingsPage.${errorKey}`); + } else { + this.alertService.error('artemisApp.userSettings.sshSettingsPage.saveFailure'); + } + }, + }); + } + + goBack() { + if (this.isCreateMode) { + this.router.navigate(['../'], { relativeTo: this.route }); + } else { + this.router.navigate(['../../'], { relativeTo: this.route }); + } + } + + validateExpiryDate() { + this.isExpiryDateValid = !!this.displayedExpiryDate?.isValid(); + } + + private setMessageBasedOnOS(os: string): void { + switch (os) { + case 'Windows': + this.copyInstructions = 'cat ~/.ssh/id_ed25519.pub | clip'; + break; + case 'MacOS': + this.copyInstructions = 'pbcopy < ~/.ssh/id_ed25519.pub'; + break; + case 'Linux': + this.copyInstructions = 'xclip -selection clipboard < ~/.ssh/id_ed25519.pub'; + break; + case 'Android': + this.copyInstructions = 'termux-clipboard-set < ~/.ssh/id_ed25519.pub'; + break; + default: + this.copyInstructions = 'Ctrl + C'; + } + } +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.html new file mode 100644 index 000000000000..44df3d891ced --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.html @@ -0,0 +1,56 @@ +

+ +
+ +
+
+

+ + + +

+
+ + @if (sshFingerprints && sshFingerprints['RSA']) { +
+
+ {{ 'RSA' }} +
+
+ {{ sshFingerprints['RSA'] }} +
+
+ } + + @if (sshFingerprints && sshFingerprints['ED25519']) { +
+
+ {{ 'ED25519' }} +
+
+ {{ sshFingerprints['ED25519'] }} +
+
+ } + + @if (sshFingerprints && sshFingerprints['ECDSA']) { +
+
+ {{ 'ECDSA' }} +
+
+ {{ sshFingerprints['ECDSA'] }} +
+
+ } + +
+
+
+ + + +
+
+
+
diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.scss b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.scss new file mode 100644 index 000000000000..61b3ac821994 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.scss @@ -0,0 +1,12 @@ +.column { + float: left; + padding: 10px; +} + +.left { + width: 15%; +} + +.right { + width: 85%; +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.ts new file mode 100644 index 000000000000..f33d4ccaf34f --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/fingerprints/ssh-user-settings-fingerprints.component.ts @@ -0,0 +1,24 @@ +import { Component, OnInit, inject } from '@angular/core'; +import { SshUserSettingsFingerprintsService } from 'app/shared/user-settings/ssh-settings/ssh-user-settings-fingerprints.service'; +import { ButtonSize, ButtonType } from 'app/shared/components/button.component'; +import { DocumentationType } from 'app/shared/components/documentation-button/documentation-button.component'; + +@Component({ + selector: 'jhi-account-information', + templateUrl: './ssh-user-settings-fingerprints.component.html', + styleUrls: ['./ssh-user-settings-fingerprints.component.scss', '../ssh-user-settings.component.scss'], +}) +export class SshUserSettingsFingerprintsComponent implements OnInit { + readonly sshUserSettingsService = inject(SshUserSettingsFingerprintsService); + + protected sshFingerprints?: { [key: string]: string }; + + readonly documentationType: DocumentationType = 'SshSetup'; + protected readonly ButtonType = ButtonType; + + protected readonly ButtonSize = ButtonSize; + + async ngOnInit() { + this.sshFingerprints = await this.sshUserSettingsService.getSshFingerprints(); + } +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings-fingerprints.service.ts b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings-fingerprints.service.ts new file mode 100644 index 000000000000..085386a5cb57 --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings-fingerprints.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { firstValueFrom } from 'rxjs'; +import { HttpClient } from '@angular/common/http'; + +@Injectable({ providedIn: 'root' }) +export class SshUserSettingsFingerprintsService { + error?: string; + + constructor(private http: HttpClient) {} + + public async getSshFingerprints(): Promise<{ [key: string]: string }> { + return await firstValueFrom(this.http.get<{ [key: string]: string }>('api/ssh-fingerprints')); + } +} diff --git a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html index 7372a07ac241..be96fb1808f4 100644 --- a/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html @@ -1,10 +1,10 @@ -

- -

-@if (currentUser) { + +

+ +@if (!isLoading) {
- @if (keyCount === 0 && !showSshKey) { + @if (keyCount === 0) {

@@ -17,22 +17,17 @@

- + + +

} - @if (keyCount > 0 && !showSshKey) { -
-

- -
+ @if (keyCount > 0) { +
+

@@ -48,108 +43,77 @@

- - -
-
- {{ sshKeyHash }} -
- + @for (key of sshPublicKeys; track key; let i = $index) { + + +
+ {{ key.label }} +
+
+ {{ key.keyHash }} +
+ @if (key.expiryDate) { +
+
+
+ {{ key.expiryDate | artemisDate: 'long-date' }} +
+
+ } + + + +