Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Integrated code lifecycle: Support multiple SSH keys per user #9478

Open
wants to merge 59 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 9 commits
Commits
Show all changes
59 commits
Select commit Hold shift + click to select a range
3786b60
added new table to add multiple keys for users
SimonEntholzer Oct 14, 2024
4922371
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 14, 2024
94f39c3
enable cloning
SimonEntholzer Oct 15, 2024
af8f80b
fixed css
SimonEntholzer Oct 17, 2024
366cbaf
revert debug change
SimonEntholzer Oct 17, 2024
6e4c8ac
added migration mysql
SimonEntholzer Oct 17, 2024
bded252
added migration for postgres
SimonEntholzer Oct 17, 2024
002de21
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 17, 2024
5b7bb96
cleaned up UI
SimonEntholzer Oct 18, 2024
7f10abe
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 20, 2024
5805139
fixed build agent authentication
SimonEntholzer Oct 20, 2024
a186891
fixed authentication in tests
SimonEntholzer Oct 20, 2024
b4e75a5
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 20, 2024
74b4a32
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 21, 2024
4b3c308
finish UI
SimonEntholzer Oct 21, 2024
9616e2d
added javadocs
SimonEntholzer Oct 21, 2024
72e6ec9
more docs
SimonEntholzer Oct 21, 2024
06d87ff
server tests
SimonEntholzer Oct 21, 2024
cbec60e
fix server style
SimonEntholzer Oct 22, 2024
f04d01e
moved deleteall
SimonEntholzer Oct 22, 2024
a61e360
add code rabbit suggestions
SimonEntholzer Oct 22, 2024
6a704e2
let code button detect if a user has ssh keys or not
SimonEntholzer Oct 22, 2024
11994ee
improve Server test coverage
SimonEntholzer Oct 22, 2024
f4cde1b
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 22, 2024
e0a01c4
added date picker
SimonEntholzer Oct 22, 2024
fb2b67a
cleaned up client code and added tests
SimonEntholzer Oct 22, 2024
75e3a0b
Use DTO instead of database entity
SimonEntholzer Oct 22, 2024
52c6b3b
moved DTO
SimonEntholzer Oct 22, 2024
c90d4e7
add change suggestions
SimonEntholzer Oct 22, 2024
73e147a
remove debugging statement
SimonEntholzer Oct 22, 2024
71ef085
fix client test style
SimonEntholzer Oct 23, 2024
54c64f1
Merge remote-tracking branch 'refs/remotes/origin/develop' into featu…
SimonEntholzer Oct 23, 2024
7d92399
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 24, 2024
8f96960
replaced put with post
SimonEntholzer Oct 24, 2024
abc1f0f
remove button
SimonEntholzer Oct 24, 2024
8ab97e3
fix copy paste error
SimonEntholzer Oct 24, 2024
9aa50d5
fix tests
SimonEntholzer Oct 24, 2024
7de1858
fix typo
SimonEntholzer Oct 24, 2024
e8ac909
improved client coverage
SimonEntholzer Oct 24, 2024
2079800
import correct dayjs
SimonEntholzer Oct 24, 2024
2c7e18e
remove unused translation keys
SimonEntholzer Oct 25, 2024
c29d5d3
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 25, 2024
3f179a4
added suggestions
SimonEntholzer Oct 26, 2024
6807a32
handle case when user inputs too long label
SimonEntholzer Oct 26, 2024
39b1458
improve error handling when deleting key
SimonEntholzer Oct 26, 2024
1038cf2
add code rabbit suggestions
SimonEntholzer Oct 26, 2024
3b1a0db
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 26, 2024
44b33ec
add code rabbit suggestions
SimonEntholzer Oct 26, 2024
0219251
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 28, 2024
6086881
fix expected http error code in test
SimonEntholzer Oct 28, 2024
56b7d53
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 28, 2024
8e4a98b
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 28, 2024
c92dc8f
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 29, 2024
46a755c
Update src/main/java/de/tum/cit/aet/artemis/programming/service/UserS…
SimonEntholzer Oct 29, 2024
4d521ef
add code suggestions
SimonEntholzer Oct 29, 2024
d5ca308
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Oct 30, 2024
e06bf0b
remove empty line from master.xml from merge conflict
SimonEntholzer Oct 30, 2024
2fcd95c
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
SimonEntholzer Nov 1, 2024
1121dab
Merge branch 'develop' into feature/localVC/support-multiple-SSH-keys
krusche Nov 2, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -45,8 +45,9 @@
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.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.
Expand All @@ -67,6 +68,8 @@ public class AccountResource {

private final UserService userService;

private final UserSshPublicKeyService userSshPublicKeyService;

private final UserCreationService userCreationService;

private final AccountService accountService;
Expand All @@ -75,13 +78,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;
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
}

/**
Expand Down Expand Up @@ -132,6 +136,32 @@ public ResponseEntity<Void> changePassword(@RequestBody PasswordChangeDTO passwo
return ResponseEntity.ok().build();
}

/**
* GET account/ssh-public-keys : sets the ssh public key
*
* @return the ResponseEntity containing all public SSH keys of a user with status 200 (OK), or with status 400 (Bad Request)
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
*/
@GetMapping("account/ssh-public-keys")
@EnforceAtLeastStudent
public ResponseEntity<List<UserSshPublicKey>> getSshPublicKey() {
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
User user = userRepository.getUser();
List<UserSshPublicKey> keys = userSshPublicKeyService.getAllSshKeysForUser(user);
return ResponseEntity.ok(keys);
}

/**
* GET account/ssh-public-key : sets the ssh public key
*
* @return the ResponseEntity containing the requested public SSH key of a user with status 200 (OK), or with status 400 (Bad Request)
*/
@GetMapping("account/ssh-public-key")
@EnforceAtLeastStudent
public ResponseEntity<UserSshPublicKey> getSshPublicKey(@RequestParam("keyId") Long keyId) {
User user = userRepository.getUser();
UserSshPublicKey key = userSshPublicKeyService.getSshKeyForUser(user, keyId);
return ResponseEntity.ok(key);
}

/**
* PUT account/ssh-public-key : sets the ssh public key
*
Expand All @@ -141,22 +171,21 @@ public ResponseEntity<Void> changePassword(@RequestBody PasswordChangeDTO passwo
*/
@PutMapping("account/ssh-public-key")
@EnforceAtLeastStudent
public ResponseEntity<Void> addSshPublicKey(@RequestBody String sshPublicKey) throws GeneralSecurityException, IOException {
public ResponseEntity<Void> addSshPublicKey(@RequestBody UserSshPublicKey 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.getPublicKey());
}
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);
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
// userRepository.updateUserSshPublicKeyHash(user.getId(), keyHash, sshPublicKey);
return ResponseEntity.ok().build();
}

Expand All @@ -167,12 +196,12 @@ public ResponseEntity<Void> addSshPublicKey(@RequestBody String sshPublicKey) th
*/
@DeleteMapping("account/ssh-public-key")
@EnforceAtLeastStudent
public ResponseEntity<Void> deleteSshPublicKey() {
public ResponseEntity<Void> deleteSshPublicKey(@RequestParam("keyId") 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();
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
package de.tum.cit.aet.artemis.programming.domain;

import java.time.ZonedDateTime;
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

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 {
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

/**
* The user who is owner of the public key
*/
@NotNull
@Column(name = "user_id")
private Long userId;
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

/**
* 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
*/
@Column(name = "public_key")
private String publicKey;
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

/**
* A hash of the public ssh key for fast comparison in the database (with an index)
*/
@Nullable
@Size(max = 100)
@Column(name = "key_hash")
private String keyHash;
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

/**
* The expiry date of the public SSH key
*/
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
@Column(name = "creation_date")
private ZonedDateTime creationDate = null;
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

/**
* The expiry date of the public SSH key
*/
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
@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;
}
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

@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;
}
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved

@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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
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 UserPublicSshKeyRepository extends ArtemisJpaRepository<UserSshPublicKey, Long> {

List<UserSshPublicKey> findAllByUserId(Long userId);

Optional<UserSshPublicKey> findByKeyHash(String keyHash);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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.List;
import java.util.Objects;

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.core.exception.EntityNotFoundException;
import de.tum.cit.aet.artemis.programming.domain.UserSshPublicKey;
import de.tum.cit.aet.artemis.programming.repository.UserPublicSshKeyRepository;
import de.tum.cit.aet.artemis.programming.service.localvc.ssh.HashUtils;

@Profile(PROFILE_CORE)
@Service
public class UserSshPublicKeyService {

private final UserPublicSshKeyRepository userPublicSshKeyRepository;

public UserSshPublicKeyService(UserPublicSshKeyRepository userPublicSshKeyRepository) {
this.userPublicSshKeyRepository = userPublicSshKeyRepository;
}

public void createSshKeyForUser(User user, AuthorizedKeyEntry keyEntry, UserSshPublicKey sshPublicKey) throws GeneralSecurityException, IOException {
PublicKey publicKey = keyEntry.resolvePublicKey(null, null, null);
String keyHash = HashUtils.getSha512Fingerprint(publicKey);

if (userPublicSshKeyRepository.findByKeyHash(keyHash).isPresent()) {
throw new BadRequestAlertException("Invalid SSH key format", "SSH key", "invalidKeyFormat", true);
}

UserSshPublicKey newUserSshPublicKey = new UserSshPublicKey();
newUserSshPublicKey.setUserId(user.getId());
newUserSshPublicKey.setLabel(sshPublicKey.getLabel());
newUserSshPublicKey.setPublicKey(sshPublicKey.getPublicKey());
newUserSshPublicKey.setKeyHash(keyHash);
newUserSshPublicKey.setExpiryDate(sshPublicKey.getExpiryDate());
newUserSshPublicKey.setCreationDate(ZonedDateTime.now());
newUserSshPublicKey.setExpiryDate(sshPublicKey.getExpiryDate());
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
SimonEntholzer marked this conversation as resolved.
Show resolved Hide resolved
userPublicSshKeyRepository.save(newUserSshPublicKey);
}

public UserSshPublicKey getSshKeyForUser(User user, Long keyId) {
var userSshPublicKey = userPublicSshKeyRepository.findByIdElseThrow(keyId);
if (Objects.equals(userSshPublicKey.getUserId(), user.getId())) {
return userSshPublicKey;
}
else {
throw new EntityNotFoundException();
}
}

public List<UserSshPublicKey> getAllSshKeysForUser(User user) {
return userPublicSshKeyRepository.findAllByUserId(user.getId());
}

public void deleteUserSshPublicKey(Long userId, Long keyId) {
var keys = userPublicSshKeyRepository.findAllByUserId(userId);
if (!keys.isEmpty() && keys.stream().map(UserSshPublicKey::getId).toList().contains(keyId)) {
userPublicSshKeyRepository.deleteById(keyId);
}
else {
throw new AccessForbiddenException("SSH key", keyId);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

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.repository.UserPublicSshKeyRepository;
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;
Expand All @@ -32,19 +33,27 @@ public class GitPublickeyAuthenticatorService implements PublickeyAuthenticator

private final Optional<SharedQueueManagementService> localCIBuildJobQueueService;

public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional<SharedQueueManagementService> localCIBuildJobQueueService) {
private final UserPublicSshKeyRepository userPublicSshKeyRepository;

public GitPublickeyAuthenticatorService(UserRepository userRepository, Optional<SharedQueueManagementService> localCIBuildJobQueueService,
UserPublicSshKeyRepository userPublicSshKeyRepository) {
this.userRepository = userRepository;
this.localCIBuildJobQueueService = localCIBuildJobQueueService;
this.userPublicSshKeyRepository = userPublicSshKeyRepository;
}

@Override
public boolean authenticate(String username, PublicKey publicKey, ServerSession session) {
String keyHash = HashUtils.getSha512Fingerprint(publicKey);
var user = userRepository.findBySshPublicKeyHash(keyHash);
var userSshPublicKey = userPublicSshKeyRepository.findByKeyHash(keyHash);
if (userSshPublicKey.isEmpty()) {
return false;
}
var user = userRepository.findById(userSshPublicKey.get().getUserId());
if (user.isPresent()) {
try {
// Retrieve the stored public key string
String storedPublicKeyString = user.get().getSshPublicKey();
String storedPublicKeyString = userSshPublicKey.get().getPublicKey();

// Parse the stored public key string
AuthorizedKeyEntry keyEntry = AuthorizedKeyEntry.parseAuthorizedKeyEntry(storedPublicKeyString);
Expand Down
Loading
Loading