diff --git a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java index 2ef2478cf295..c435194cdf40 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/domain/User.java @@ -156,23 +156,6 @@ public class User extends AbstractAuditingEntity implements Participant { @Column(name = "vcs_access_token_expiry_date") private ZonedDateTime vcsAccessTokenExpiryDate = null; - /** - * The actual full public ssh key of a user used to authenticate git clone and git push operations if available - */ - @Nullable - @JsonIgnore - @Column(name = "ssh_public_key") - private final String sshPublicKey = null; - - /** - * A hash of the public ssh key for fast comparison in the database (with an index) - */ - @Nullable - @Size(max = 100) - @JsonIgnore - @Column(name = "ssh_public_key_hash") - private final String sshPublicKeyHash = null; - @ElementCollection(fetch = FetchType.LAZY) @CollectionTable(name = "user_groups", joinColumns = @JoinColumn(name = "user_id")) @Column(name = "user_groups") @@ -560,14 +543,4 @@ public void hasAcceptedIrisElseThrow() { throw new AccessForbiddenException("The user has not accepted the Iris privacy policy yet."); } } - - @Nullable - public String getSshPublicKey() { - return sshPublicKey; - } - - @Nullable - public @Size(max = 100) String getSshPublicKeyHash() { - return sshPublicKeyHash; - } } diff --git a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java index 3d627425a8f7..1f5ea1e653b8 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/dto/UserDTO.java @@ -76,10 +76,6 @@ public class UserDTO extends AuditingEntityDTO { private ZonedDateTime vcsAccessTokenExpiryDate; - private String sshPublicKey; - - private String sshKeyHash; - private ZonedDateTime irisAccepted; public UserDTO() { @@ -262,14 +258,6 @@ public ZonedDateTime getVcsAccessTokenExpiryDate() { return vcsAccessTokenExpiryDate; } - public String getSshPublicKey() { - return sshPublicKey; - } - - public void setSshPublicKey(String sshPublicKey) { - this.sshPublicKey = sshPublicKey; - } - @Override public String toString() { return "UserDTO{" + "login='" + login + '\'' + ", firstName='" + firstName + '\'' + ", lastName='" + lastName + '\'' + ", email='" + email + '\'' + ", imageUrl='" @@ -293,12 +281,4 @@ public ZonedDateTime getIrisAccepted() { public void setIrisAccepted(ZonedDateTime irisAccepted) { this.irisAccepted = irisAccepted; } - - public String getSshKeyHash() { - return sshKeyHash; - } - - public void setSshKeyHash(String sshKeyHash) { - this.sshKeyHash = sshKeyHash; - } } 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 997574c76da7..16c94629047c 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 @@ -2,19 +2,15 @@ import static de.tum.cit.aet.artemis.core.config.Constants.PROFILE_CORE; -import java.io.IOException; import java.net.URI; import java.net.URISyntaxException; import java.nio.file.Path; -import java.security.GeneralSecurityException; -import java.security.PublicKey; import java.time.ZonedDateTime; import java.util.Optional; import jakarta.validation.Valid; import jakarta.ws.rs.BadRequestException; -import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.context.annotation.Profile; @@ -45,7 +41,6 @@ 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.service.localvc.LocalVCPersonalAccessTokenManagementService; -import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils; /** * REST controller for managing the current user's account. @@ -128,50 +123,6 @@ public ResponseEntity changePassword(@RequestBody PasswordChangeDTO passwo return ResponseEntity.ok().build(); } - /** - * PUT account/ssh-public-key : sets the ssh public key - * - * @param sshPublicKey the ssh public key to set - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @PutMapping("account/ssh-public-key") - @EnforceAtLeastStudent - public ResponseEntity addSshPublicKey(@RequestBody String 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); - } - 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); - return ResponseEntity.ok().build(); - } - - /** - * PUT account/ssh-public-key : sets the ssh public key - * - * @return the ResponseEntity with status 200 (OK), with status 404 (Not Found), or with status 400 (Bad Request) - */ - @DeleteMapping("account/ssh-public-key") - @EnforceAtLeastStudent - public ResponseEntity deleteSshPublicKey() { - User user = userRepository.getUser(); - log.debug("REST request to remove SSH key of user {}", user.getLogin()); - userRepository.updateUserSshPublicKeyHash(user.getId(), null, null); - - log.debug("Successfully deleted SSH key of user {}", user.getLogin()); - return ResponseEntity.ok().build(); - } - /** * PUT account/user-vcs-access-token : creates a vcsAccessToken for a user * diff --git a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java index bd673ece51d4..543ad85964da 100644 --- a/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java +++ b/src/main/java/de/tum/cit/aet/artemis/core/web/open/PublicAccountResource.java @@ -166,8 +166,6 @@ public ResponseEntity getAccount() { // we set this value on purpose here: the user can only fetch their own information, make the token available for constructing the token-based clone-URL userDTO.setVcsAccessToken(user.getVcsAccessToken()); userDTO.setVcsAccessTokenExpiryDate(user.getVcsAccessTokenExpiryDate()); - userDTO.setSshPublicKey(user.getSshPublicKey()); - userDTO.setSshKeyHash(user.getSshPublicKeyHash()); log.info("GET /account {} took {}ms", user.getLogin(), System.currentTimeMillis() - start); return ResponseEntity.ok(userDTO); } 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..232afd327663 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/service/UserSshPublicKeyService.java @@ -0,0 +1,139 @@ +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 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 " + (userSshPublicKeyRepository.findAllByUserId(newSshPublicKey.getUserId()).size() + 1); + } + } + 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..3c9bc85f83a4 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; @@ -13,11 +14,14 @@ import org.apache.sshd.server.session.ServerSession; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Service; 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 +36,85 @@ public class GitPublickeyAuthenticatorService implements PublickeyAuthenticator private final Optional localCIBuildJobQueueService; - public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional localCIBuildJobQueueService) { + private final UserSshPublicKeyRepository userSshPublicKeyRepository; + + private final int AUTHENTICATION_FAILED_CODE = 10; + + @Value("${server.url}") + private String artemisServerUrl; + + 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); + } + else { + disconnectBecauseKeyHasExpired(session); + } + + 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; } - catch (Exception e) { - log.error("Failed to convert stored public key string to PublicKey object", e); + else { + log.warn("Public key mismatch for user {}", user.get().getLogin()); } } - else if (localCIBuildJobQueueService.isPresent() - && localCIBuildJobQueueService.get().getBuildAgentInformation().stream().anyMatch(agent -> checkPublicKeyMatchesBuildAgentPublicKey(agent, publicKey))) { + catch (Exception e) { + log.error("Failed to convert stored public key string to PublicKey object", e); + } + 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 +122,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; @@ -90,4 +146,25 @@ private boolean checkPublicKeyMatchesBuildAgentPublicKey(BuildAgentInformation a return agentPublicKey.equals(publicKey); } + + /** + * Disconnects the client from the session and informs that the key used to authenticate with has expired + * + * @param session the session with the client + */ + private void disconnectBecauseKeyHasExpired(ServerSession session) { + try { + var keyExpiredErrorMessage = String.format(""" + Keys expired. + + One of your SSH keys has expired. Renew it in the Artemis settings: + %s/user-settings/ssh + """, artemisServerUrl); + + session.disconnect(AUTHENTICATION_FAILED_CODE, keyExpiredErrorMessage); + } + catch (IOException e) { + log.info("Failed to disconnect SSH client session {}", e.getMessage()); + } + } } diff --git a/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java new file mode 100644 index 000000000000..8eb38fdfd263 --- /dev/null +++ b/src/main/java/de/tum/cit/aet/artemis/programming/web/localvc/ssh/SshPublicKeysResource.java @@ -0,0 +1,117 @@ +package de.tum.cit.aet.artemis.programming.web.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.List; + +import org.apache.sshd.common.config.keys.AuthorizedKeyEntry; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Profile; +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.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import de.tum.cit.aet.artemis.core.domain.User; +import de.tum.cit.aet.artemis.core.exception.BadRequestAlertException; +import de.tum.cit.aet.artemis.core.repository.UserRepository; +import de.tum.cit.aet.artemis.core.security.annotations.EnforceAtLeastStudent; +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; + +@Profile(PROFILE_LOCALVC) +@RestController +@RequestMapping("api/ssh-settings/") +public class SshPublicKeysResource { + + private static final Logger log = LoggerFactory.getLogger(SshPublicKeysResource.class); + + private final UserSshPublicKeyService userSshPublicKeyService; + + private final UserRepository userRepository; + + public SshPublicKeysResource(UserSshPublicKeyService userSshPublicKeyService, UserRepository userRepository) { + this.userSshPublicKeyService = userSshPublicKeyService; + this.userRepository = userRepository; + } + + /** + * GET public-keys : retrieves all SSH keys of a user + * + * @return the ResponseEntity containing all public SSH keys of a user with status 200 (OK) + */ + @GetMapping("public-keys") + @EnforceAtLeastStudent + public ResponseEntity> getSshPublicKeys() { + User user = userRepository.getUser(); + List keys = userSshPublicKeyService.getAllSshKeysForUser(user); + return ResponseEntity.ok(keys); + } + + /** + * GET public-key : gets the ssh public key + * + * @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("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)); + } + + /** + * POST 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("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()); + AuthorizedKeyEntry keyEntry; + try { + keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(sshPublicKey.publicKey()); + } + catch (IllegalArgumentException e) { + throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true); + } + + userSshPublicKeyService.createSshKeyForUser(user, keyEntry, sshPublicKey); + return ResponseEntity.ok().build(); + } + + /** + * Delete - public-key : deletes the ssh public key by its keyId + * + * @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("public-key/{keyId}") + @EnforceAtLeastStudent + public ResponseEntity deleteSshPublicKey(@PathVariable Long keyId) { + User user = userRepository.getUser(); + log.debug("REST request to remove SSH key of user {}", user.getLogin()); + userSshPublicKeyService.deleteUserSshPublicKey(user.getId(), keyId); + + log.debug("Successfully deleted SSH key with id {} of user {}", keyId, user.getLogin()); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml b/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml new file mode 100644 index 000000000000..0e7e5e68aac6 --- /dev/null +++ b/src/main/resources/config/liquibase/changelog/20241105150000_changelog.xml @@ -0,0 +1,59 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 e29f09657055..d331337ceef4 100644 --- a/src/main/resources/config/liquibase/master.xml +++ b/src/main/resources/config/liquibase/master.xml @@ -29,11 +29,12 @@ - + + diff --git a/src/main/webapp/app/core/auth/account.service.ts b/src/main/webapp/app/core/auth/account.service.ts index a89944954c05..8745ae5357b9 100644 --- a/src/main/webapp/app/core/auth/account.service.ts +++ b/src/main/webapp/app/core/auth/account.service.ts @@ -30,7 +30,6 @@ export interface IAccountService { isAuthenticated: () => boolean; getAuthenticationState: () => Observable; getImageUrl: () => string | undefined; - addSshPublicKey: (sshPublicKey: string) => Observable; } @Injectable({ providedIn: 'root' }) @@ -325,28 +324,6 @@ export class AccountService implements IAccountService { this.prefilledUsernameValue = prefilledUsername; } - /** - * Sends the added SSH key to the server - * - * @param sshPublicKey - */ - addSshPublicKey(sshPublicKey: string): Observable { - if (this.userIdentity) { - this.userIdentity.sshPublicKey = sshPublicKey; - } - return this.http.put('api/account/ssh-public-key', sshPublicKey); - } - - /** - * 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'); - } - /** * Sends a request to the server to delete the user's current vcsAccessToken */ diff --git a/src/main/webapp/app/core/user/user.model.ts b/src/main/webapp/app/core/user/user.model.ts index f793581b21a3..52fa28f56ab9 100644 --- a/src/main/webapp/app/core/user/user.model.ts +++ b/src/main/webapp/app/core/user/user.model.ts @@ -15,8 +15,6 @@ export class User extends Account { public password?: string; public vcsAccessToken?: string; public vcsAccessTokenExpiryDate?: string; - public sshPublicKey?: string; - public sshKeyHash?: string; public irisAccepted?: dayjs.Dayjs; constructor( @@ -38,7 +36,6 @@ export class User extends Account { imageUrl?: string, vcsAccessToken?: string, vcsAccessTokenExpiryDate?: string, - sshPublicKey?: string, irisAccepted?: dayjs.Dayjs, ) { super(activated, authorities, email, firstName, langKey, lastName, login, imageUrl); @@ -52,7 +49,6 @@ export class User extends Account { this.password = password; this.vcsAccessToken = vcsAccessToken; this.vcsAccessTokenExpiryDate = vcsAccessTokenExpiryDate; - this.sshPublicKey = sshPublicKey; this.irisAccepted = irisAccepted; } } 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.html b/src/main/webapp/app/shared/components/code-button/code-button.component.html index 80972a802a38..c8510599a4e6 100644 --- a/src/main/webapp/app/shared/components/code-button/code-button.component.html +++ b/src/main/webapp/app/shared/components/code-button/code-button.component.html @@ -14,9 +14,12 @@ container="body" > - @if (useSsh && !copyEnabled) { + @if (useSsh && !doesUserHaveSSHkeys) {
} + @if (useSsh && areAnySshKeysExpired) { +
+ } @if (useToken && tokenMissing) {
} 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 7e4e0d33e766..36046020db1b 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 @@ -16,6 +16,8 @@ import { isPracticeMode } from 'app/entities/participation/student-participation import { faCode, faExternalLink } from '@fortawesome/free-solid-svg-icons'; import { IdeSettingsService } from 'app/shared/user-settings/ide-preferences/ide-settings.service'; import { Ide } from 'app/shared/user-settings/ide-preferences/ide.model'; +import { SshUserSettingsService } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.service'; +import { UserSshPublicKey } from 'app/entities/programming/user-ssh-public-key.model'; @Component({ selector: 'jhi-code-button', @@ -50,12 +52,16 @@ export class CodeButtonComponent implements OnInit, OnChanges { gitlabVCEnabled = false; showCloneUrlWithoutToken = true; copyEnabled? = true; + doesUserHaveSSHkeys = false; + areAnySshKeysExpired = false; sshKeyMissingTip: string; + sshKeysExpiredTip: string; tokenMissingTip: string; tokenExpiredTip: string; user: User; + sshKeys?: UserSshPublicKey[]; cloneHeadline: string; wasCopied = false; isTeamParticipation: boolean; @@ -73,6 +79,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { constructor( private translateService: TranslateService, private externalCloningService: ExternalCloningService, + private sshUserSettingsService: SshUserSettingsService, private accountService: AccountService, private profileService: ProfileService, private localStorage: LocalStorageService, @@ -87,6 +94,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { } this.user = user; + await this.checkForSshKeys(); this.refreshTokenState(); this.copyEnabled = true; @@ -116,6 +124,7 @@ export class CodeButtonComponent implements OnInit, OnChanges { this.sshSettingsUrl = profileInfo.sshKeysURL; } this.sshKeyMissingTip = this.formatTip('artemisApp.exerciseActions.sshKeyTip', this.sshSettingsUrl); + this.sshKeysExpiredTip = this.formatTip('artemisApp.exerciseActions.sshKeyExpiredTip', this.sshSettingsUrl); if (this.useSsh) { this.useSshUrl(); @@ -152,7 +161,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(); } @@ -347,4 +356,20 @@ export class CodeButtonComponent implements OnInit, OnChanges { this.user.vcsAccessToken = this.activeParticipation.vcsAccessToken; } } + + /** + * Checks whether the user owns any SSH keys, and checks if any of them is expired + */ + private async checkForSshKeys() { + this.sshKeys = await this.sshUserSettingsService.getCachedSshKeys(); + if (this.sshKeys) { + const now = dayjs(); + this.doesUserHaveSSHkeys = this.sshKeys.length > 0; + this.areAnySshKeysExpired = this.sshKeys.some((key) => { + if (key.expiryDate) { + return dayjs(key.expiryDate).isBefore(now); + } + }); + } + } } 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..459a0c2edc4e --- /dev/null +++ b/src/main/webapp/app/shared/user-settings/ssh-settings/details/ssh-user-settings-key-details.component.ts @@ -0,0 +1,141 @@ +import { Component, OnDestroy, OnInit, inject } from '@angular/core'; +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'; +import { SshUserSettingsService } from 'app/shared/user-settings/ssh-settings/ssh-user-settings.service'; + +@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 { + private sshUserSettingsService = inject(SshUserSettingsService); + 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.sshUserSettingsService.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.sshUserSettingsService.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() { + this.router.navigate(['/user-settings/ssh']); + } + + 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/ssh-user-settings.component.html b/src/main/webapp/app/shared/user-settings/ssh-settings/ssh-user-settings.component.html index 7372a07ac241..a5650cef05a9 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,21 +17,16 @@

- + + +

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

- + @if (keyCount > 0) { +

@@ -48,108 +43,72 @@

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