diff --git a/citesphere/pom.xml b/citesphere/pom.xml index 871f987ae..23af8c66b 100644 --- a/citesphere/pom.xml +++ b/citesphere/pom.xml @@ -542,14 +542,23 @@ 4.13.1 test - + + + org.mockito mockito-all 1.10.19 test + + + org.mockito + mockito-core + 3.12.4 + test + diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/exceptions/CannotFindTokenException.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/exceptions/CannotFindTokenException.java new file mode 100644 index 000000000..3b669e247 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/exceptions/CannotFindTokenException.java @@ -0,0 +1,10 @@ +package edu.asu.diging.citesphere.core.exceptions; + +public class CannotFindTokenException extends Exception { + + private static final long serialVersionUID = 1L; + + public CannotFindTokenException(String message) { + super(message); + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/IPersonalAccessToken.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/IPersonalAccessToken.java new file mode 100644 index 000000000..589f9143c --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/IPersonalAccessToken.java @@ -0,0 +1,24 @@ +package edu.asu.diging.citesphere.core.model.oauth; + +import java.time.OffsetDateTime; + +/** + * Interface representing a personal access token for API access. + * Personal access tokens allow users to authenticate against the API + * without going through the OAuth authorization code flow. + */ +public interface IPersonalAccessToken { + + String getId(); + + String getName(); + void setName(String name); + + String getUsername(); + + OffsetDateTime getCreatedAt(); + void setCreatedAt(OffsetDateTime createdAt); + + boolean isPersonalAccessToken(); + void setPersonalAccessToken(boolean isPersonalAccessToken); +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/DbAccessToken.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/DbAccessToken.java index fc03a63ba..cf7d79ad5 100644 --- a/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/DbAccessToken.java +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/DbAccessToken.java @@ -1,5 +1,7 @@ package edu.asu.diging.citesphere.core.model.oauth.impl; +import java.time.OffsetDateTime; + import javax.persistence.Entity; import javax.persistence.Id; import javax.persistence.Lob; @@ -7,6 +9,8 @@ import org.springframework.security.oauth2.common.OAuth2AccessToken; import org.springframework.security.oauth2.provider.OAuth2Authentication; +import edu.asu.diging.citesphere.core.model.oauth.IPersonalAccessToken; + /** * Modeled after: * https://blog.couchbase.com/custom-token-store-spring-securtiy-oauth2/ @@ -14,8 +18,8 @@ * */ @Entity -public class DbAccessToken { - +public class DbAccessToken implements IPersonalAccessToken { + @Id private String id; private String tokenId; @@ -28,12 +32,15 @@ public class DbAccessToken { private String authentication; @Lob private String refreshToken; - - + + private String name; + private OffsetDateTime createdAt; + private boolean personalAccessToken; + public OAuth2Authentication getAuthentication() { return SerializableObjectConverter.deserialize(authentication); } - + public void setAuthentication(OAuth2Authentication authentication) { this.authentication = SerializableObjectConverter.serialize(authentication); } @@ -97,5 +104,34 @@ public void setRefreshToken(String refreshToken) { public void setAuthentication(String authentication) { this.authentication = authentication; } - -} \ No newline at end of file + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + this.name = name; + } + + @Override + public OffsetDateTime getCreatedAt() { + return createdAt; + } + + @Override + public void setCreatedAt(OffsetDateTime createdAt) { + this.createdAt = createdAt; + } + + @Override + public boolean isPersonalAccessToken() { + return personalAccessToken; + } + + @Override + public void setPersonalAccessToken(boolean personalAccessToken) { + this.personalAccessToken = personalAccessToken; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/OAuthClient.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/OAuthClient.java index b213e99ab..67b43da99 100644 --- a/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/OAuthClient.java +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/OAuthClient.java @@ -5,10 +5,15 @@ import java.util.Set; import javax.persistence.CascadeType; +import javax.persistence.DiscriminatorColumn; +import javax.persistence.DiscriminatorType; +import javax.persistence.DiscriminatorValue; import javax.persistence.ElementCollection; import javax.persistence.Entity; import javax.persistence.GeneratedValue; import javax.persistence.Id; +import javax.persistence.Inheritance; +import javax.persistence.InheritanceType; import javax.persistence.OneToOne; import org.hibernate.annotations.GenericGenerator; @@ -21,6 +26,9 @@ import edu.asu.diging.citesphere.user.impl.User; @Entity +@Inheritance(strategy = InheritanceType.SINGLE_TABLE) +@DiscriminatorColumn(name = "DTYPE", discriminatorType = DiscriminatorType.STRING) +@DiscriminatorValue("OAUTH") public class OAuthClient implements IOAuthClient, ClientDetails { /** @@ -50,6 +58,7 @@ public class OAuthClient implements IOAuthClient, ClientDetails { private int accessTokenValiditySeconds; private int refereshTokenValiditySeconds; private boolean autoApprove; + @OneToOne(targetEntity=User.class) private IUser createdBy; @@ -199,7 +208,6 @@ public void setAutoApprove(boolean autoApprove) { this.autoApprove = autoApprove; } - @Override public IUser getCreatedBy() { return this.createdBy; } diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/PersonalAccessTokenOAuthClient.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/PersonalAccessTokenOAuthClient.java new file mode 100644 index 000000000..4948468b5 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/model/oauth/impl/PersonalAccessTokenOAuthClient.java @@ -0,0 +1,15 @@ +package edu.asu.diging.citesphere.core.model.oauth.impl; + +import javax.persistence.DiscriminatorValue; +import javax.persistence.Entity; + +/** + * Subclass of OAuthClient specifically for personal access tokens. + * The type itself identifies PAT clients + */ +@Entity +@DiscriminatorValue("PAT") +public class PersonalAccessTokenOAuthClient extends OAuthClient { + + private static final long serialVersionUID = 1L; +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/DbAccessTokenRepository.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/DbAccessTokenRepository.java index be60818bd..e3f97dfa1 100644 --- a/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/DbAccessTokenRepository.java +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/DbAccessTokenRepository.java @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import edu.asu.diging.citesphere.core.model.oauth.impl.DbAccessToken; @@ -28,5 +30,8 @@ public interface DbAccessTokenRepository extends JpaRepository findByAuthenticationId(String authenticationId); void deleteByClientIdAndUsername(String clientId, String username); - + + Page findByUsernameAndPersonalAccessToken(String username, boolean isPersonalAccessToken, Pageable pageable); + + Optional findByIdAndUsername(String id, String username); } \ No newline at end of file diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/OAuthClientRepository.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/OAuthClientRepository.java index ba5ab461a..825a3a801 100644 --- a/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/OAuthClientRepository.java +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/OAuthClientRepository.java @@ -4,6 +4,11 @@ import edu.asu.diging.citesphere.core.model.oauth.impl.OAuthClient; +/** + * Repository for OAuth clients. + * Note: Personal access token clients now have their own repository + * (PersonalAccessTokenOAuthClientRepository). + */ public interface OAuthClientRepository extends JpaRepository { } diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/PersonalAccessTokenOAuthClientRepository.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/PersonalAccessTokenOAuthClientRepository.java new file mode 100644 index 000000000..dfc6daf62 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/repository/oauth/PersonalAccessTokenOAuthClientRepository.java @@ -0,0 +1,21 @@ +package edu.asu.diging.citesphere.core.repository.oauth; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import edu.asu.diging.citesphere.core.model.oauth.impl.PersonalAccessTokenOAuthClient; + +/** + * Repository for personal access token OAuth clients. + * This repository handles the specialized PAT client subclass. + */ +public interface PersonalAccessTokenOAuthClientRepository + extends JpaRepository { + + /** + * Find a personal access token client by the username of the user who created it. + * Each user has at most one PAT client. + */ + Optional findByCreatedByUsername(String username); +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IOAuthClientManager.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IOAuthClientManager.java index fcab07b31..ea6b20ab1 100644 --- a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IOAuthClientManager.java +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IOAuthClientManager.java @@ -27,4 +27,4 @@ public interface IOAuthClientManager { OAuthCredentials updateClientSecret(String clientId) throws CannotFindClientException; List getClientsDetails(List clientList); -} \ No newline at end of file +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IPersonalAccessTokenManager.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IPersonalAccessTokenManager.java new file mode 100644 index 000000000..27775acf0 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/IPersonalAccessTokenManager.java @@ -0,0 +1,61 @@ +package edu.asu.diging.citesphere.core.service.oauth; + +import org.springframework.data.domain.Pageable; + +import edu.asu.diging.citesphere.core.exceptions.CannotFindTokenException; +import edu.asu.diging.citesphere.core.model.oauth.IPersonalAccessToken; +import edu.asu.diging.citesphere.user.IUser; + +/** + * Manager for personal access token operations. + * Personal access tokens allow users to authenticate against the API + * without going through the OAuth authorization code flow. + */ +public interface IPersonalAccessTokenManager { + + /** + * Creates a new personal access token for the user. + * + * @param name the user-given name for the token + * @param user the user creating the token + * @return credentials containing the token ID and token value (shown only once) + */ + PersonalAccessTokenCredentials createToken(String name, IUser user); + + /** + * Gets all personal access tokens for the user. + * + * @param user the user whose tokens to retrieve + * @param pageable pagination information + * @return paginated result of tokens + */ + PersonalAccessTokenResultPage getTokensForUser(IUser user, Pageable pageable); + + /** + * Deletes a personal access token. + * + * @param tokenId the ID of the token to delete + * @param user the user who owns the token + * @throws CannotFindTokenException if the token is not found or doesn't belong to the user + */ + void deleteToken(String tokenId, IUser user) throws CannotFindTokenException; + + /** + * Regenerates a personal access token, invalidating the old token value + * and creating a new one. + * + * @param tokenId the ID of the token to regenerate + * @param user the user who owns the token + * @return credentials containing the token ID and new token value + * @throws CannotFindTokenException if the token is not found or doesn't belong to the user + */ + PersonalAccessTokenCredentials regenerateToken(String tokenId, IUser user) throws CannotFindTokenException; + + /** + * Gets a personal access token by ID. + * + * @param tokenId the ID of the token + * @return the token, or null if not found + */ + IPersonalAccessToken getTokenById(String tokenId); +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/PersonalAccessTokenCredentials.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/PersonalAccessTokenCredentials.java new file mode 100644 index 000000000..9c71240f1 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/PersonalAccessTokenCredentials.java @@ -0,0 +1,43 @@ +package edu.asu.diging.citesphere.core.service.oauth; + +/** + * This class is a temporary holder for personal access token ID and token value + * to be used after creation of a new token. The token value should only be shown + * once to the user and not stored unencrypted. + */ +public class PersonalAccessTokenCredentials { + + private String tokenId; + private String tokenValue; + private String name; + + public PersonalAccessTokenCredentials(String tokenId, String tokenValue, String name) { + this.tokenId = tokenId; + this.tokenValue = tokenValue; + this.name = name; + } + + public String getTokenId() { + return tokenId; + } + + public void setTokenId(String tokenId) { + this.tokenId = tokenId; + } + + public String getTokenValue() { + return tokenValue; + } + + public void setTokenValue(String tokenValue) { + this.tokenValue = tokenValue; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/PersonalAccessTokenResultPage.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/PersonalAccessTokenResultPage.java new file mode 100644 index 000000000..52a847e26 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/PersonalAccessTokenResultPage.java @@ -0,0 +1,30 @@ +package edu.asu.diging.citesphere.core.service.oauth; + +import java.util.List; + +import edu.asu.diging.citesphere.core.model.oauth.IPersonalAccessToken; + +/** + * Result page for paginated personal access token listings. + */ +public class PersonalAccessTokenResultPage { + + private long totalPages; + private List tokens; + + public long getTotalPages() { + return totalPages; + } + + public void setTotalPages(long totalPages) { + this.totalPages = totalPages; + } + + public List getTokens() { + return tokens; + } + + public void setTokens(List tokens) { + this.tokens = tokens; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/UserAccessTokenResultPage.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/UserAccessTokenResultPage.java new file mode 100644 index 000000000..ab9ebac3c --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/UserAccessTokenResultPage.java @@ -0,0 +1,27 @@ +package edu.asu.diging.citesphere.core.service.oauth; + +import java.util.List; + +import edu.asu.diging.citesphere.core.model.oauth.IOAuthClient; +import edu.asu.diging.citesphere.core.model.oauth.IUserAccessToken; + +public class UserAccessTokenResultPage { + private long totalPages; + private List accessTokenList; + + public long getTotalPages() { + return totalPages; + } + + public void setTotalPages(long totalPages) { + this.totalPages = totalPages; + } + + public List getClientList() { + return accessTokenList; + } + + public void setClientList(List accessTokenList) { + this.accessTokenList = accessTokenList; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/OAuthClientManager.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/OAuthClientManager.java index c8d0bdd85..06c7c32bb 100644 --- a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/OAuthClientManager.java +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/OAuthClientManager.java @@ -11,9 +11,6 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.security.authentication.AuthenticationManager; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.AuthenticationException; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.oauth2.common.exceptions.InvalidClientException; @@ -35,11 +32,11 @@ public class OAuthClientManager implements ClientDetailsService, IOAuthClientManager { private OAuthClientRepository clientRepo; - + private BCryptPasswordEncoder bCryptPasswordEncoder; - + private int accessTokenValidity; - + public OAuthClientManager(OAuthClientRepository repo, BCryptPasswordEncoder bCryptPasswordEncoder, int accessTokenValidity) { this.clientRepo = repo; this.bCryptPasswordEncoder = bCryptPasswordEncoder; @@ -59,10 +56,10 @@ public ClientDetails loadClientByClientId(String clientId) throws ClientRegistra // load authorities, ugly but best I can come up with right now } return details; - } + } throw new InvalidClientException("Client with id " + clientId + " does not exist."); } - + /* (non-Javadoc) * @see edu.asu.diging.citesphere.core.service.oauth.impl.IOAuthClientManager#store(org.springframework.security.oauth2.provider.ClientDetails) */ @@ -85,7 +82,7 @@ public OAuthCredentials create(String name, String description, List OAuthClient storeClient = clientRepo.save(client); return new OAuthCredentials(storeClient.getClientId(), clientSecret); } - + @Override public OAuthClientResultPage getAllClientDetails(Pageable pageable) { List clientList = new ArrayList<>(); @@ -95,21 +92,21 @@ public OAuthClientResultPage getAllClientDetails(Pageable pageable) { result.setClientList(clientList); result.setTotalPages(oAuthClients.getTotalPages()); return result; - + } - + @Override public List getAllApps() { return clientRepo.findAll(); } - + @Override public void deleteClient(String clientId) { if(clientId != null) { clientRepo.deleteById(clientId); } } - + @Override public OAuthCredentials updateClientSecret(String clientId) throws CannotFindClientException { Optional clientOptional = clientRepo.findById(clientId); @@ -122,7 +119,7 @@ public OAuthCredentials updateClientSecret(String clientId) throws CannotFindCli } throw new CannotFindClientException("Client with id " + clientId + " does not exist."); } - + @Override public List getClientsDetails(List clientList){ List clients = new ArrayList<>(); diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/PersonalAccessTokenManager.java b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/PersonalAccessTokenManager.java new file mode 100644 index 000000000..d73963a15 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/core/service/oauth/impl/PersonalAccessTokenManager.java @@ -0,0 +1,206 @@ +package edu.asu.diging.citesphere.core.service.oauth.impl; + +import java.io.UnsupportedEncodingException; +import java.math.BigInteger; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.time.OffsetDateTime; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +import javax.transaction.Transactional; + +import org.javers.common.collections.Sets; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.oauth2.common.DefaultOAuth2AccessToken; +import org.springframework.security.oauth2.common.OAuth2AccessToken; +import org.springframework.stereotype.Service; + +import edu.asu.diging.citesphere.core.exceptions.CannotFindTokenException; +import edu.asu.diging.citesphere.core.model.Role; +import edu.asu.diging.citesphere.core.model.oauth.IPersonalAccessToken; +import edu.asu.diging.citesphere.core.model.oauth.impl.DbAccessToken; +import edu.asu.diging.citesphere.core.model.oauth.impl.PersonalAccessTokenOAuthClient; +import edu.asu.diging.citesphere.core.repository.oauth.DbAccessTokenRepository; +import edu.asu.diging.citesphere.core.repository.oauth.PersonalAccessTokenOAuthClientRepository; +import edu.asu.diging.citesphere.core.service.oauth.IPersonalAccessTokenManager; +import edu.asu.diging.citesphere.core.service.oauth.OAuthScope; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenCredentials; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenResultPage; +import edu.asu.diging.citesphere.user.IUser; + +/** + * Manager for personal access token operations. + * This class handles the creation, retrieval, and management of personal access tokens, + * which allow users to authenticate against the API without going through the OAuth + * authorization code flow. + */ +@Service +@Transactional +public class PersonalAccessTokenManager implements IPersonalAccessTokenManager { + + @Autowired + private PersonalAccessTokenOAuthClientRepository patClientRepo; + + @Autowired + private DbAccessTokenRepository accessTokenRepo; + + @Autowired + private BCryptPasswordEncoder bCryptPasswordEncoder; + + @Override + public PersonalAccessTokenCredentials createToken(String name, IUser user) { + PersonalAccessTokenOAuthClient patClient = getOrCreatePATClientForUser(user); + + OAuth2AccessToken accessToken = createAccessToken(); + + DbAccessToken dbToken = new DbAccessToken(); + dbToken.setId(UUID.randomUUID().toString() + UUID.randomUUID().toString()); + dbToken.setTokenId(extractTokenKey(accessToken.getValue())); + dbToken.setToken(accessToken); + dbToken.setUsername(user.getUsername()); + dbToken.setClientId(patClient.getClientId()); + dbToken.setName(name); + dbToken.setCreatedAt(OffsetDateTime.now()); + dbToken.setPersonalAccessToken(true); + + accessTokenRepo.save(dbToken); + + return new PersonalAccessTokenCredentials(dbToken.getId(), accessToken.getValue(), name); + } + + @Override + public PersonalAccessTokenResultPage getTokensForUser(IUser user, Pageable pageable) { + Page tokensPage = accessTokenRepo.findByUsernameAndPersonalAccessToken( + user.getUsername(), true, pageable); + + List tokens = new ArrayList<>(); + tokensPage.forEach(token -> tokens.add(token)); + + PersonalAccessTokenResultPage result = new PersonalAccessTokenResultPage(); + result.setTokens(tokens); + result.setTotalPages(tokensPage.getTotalPages()); + return result; + } + + @Override + public void deleteToken(String tokenId, IUser user) throws CannotFindTokenException { + Optional tokenOptional = accessTokenRepo.findByIdAndUsername(tokenId, user.getUsername()); + if (!tokenOptional.isPresent()) { + throw new CannotFindTokenException("Token with id " + tokenId + " does not exist or does not belong to user."); + } + + DbAccessToken token = tokenOptional.get(); + if (!token.isPersonalAccessToken()) { + throw new CannotFindTokenException("Token with id " + tokenId + " is not a personal access token."); + } + + accessTokenRepo.delete(token); + } + + @Override + public PersonalAccessTokenCredentials regenerateToken(String tokenId, IUser user) throws CannotFindTokenException { + Optional tokenOptional = accessTokenRepo.findByIdAndUsername(tokenId, user.getUsername()); + if (!tokenOptional.isPresent()) { + throw new CannotFindTokenException("Token with id " + tokenId + " does not exist or does not belong to user."); + } + + DbAccessToken oldToken = tokenOptional.get(); + if (!oldToken.isPersonalAccessToken()) { + throw new CannotFindTokenException("Token with id " + tokenId + " is not a personal access token."); + } + + String tokenName = oldToken.getName(); + + accessTokenRepo.delete(oldToken); + + PersonalAccessTokenOAuthClient patClient = getOrCreatePATClientForUser(user); + OAuth2AccessToken newAccessToken = createAccessToken(); + + DbAccessToken newDbToken = new DbAccessToken(); + newDbToken.setId(UUID.randomUUID().toString() + UUID.randomUUID().toString()); + newDbToken.setTokenId(extractTokenKey(newAccessToken.getValue())); + newDbToken.setToken(newAccessToken); + newDbToken.setUsername(user.getUsername()); + newDbToken.setClientId(patClient.getClientId()); + newDbToken.setName(tokenName); + newDbToken.setCreatedAt(OffsetDateTime.now()); + newDbToken.setPersonalAccessToken(true); + + accessTokenRepo.save(newDbToken); + + return new PersonalAccessTokenCredentials(newDbToken.getId(), newAccessToken.getValue(), tokenName); + } + + @Override + public IPersonalAccessToken getTokenById(String tokenId) { + Optional tokenOptional = accessTokenRepo.findById(tokenId); + if (tokenOptional.isPresent() && tokenOptional.get().isPersonalAccessToken()) { + return tokenOptional.get(); + } + return null; + } + + /** + * Gets or creates the personal access token OAuthClient for a user. + * Each user has exactly one PersonalAccessTokenOAuthClient that is used for all their personal access tokens. + */ + private PersonalAccessTokenOAuthClient getOrCreatePATClientForUser(IUser user) { + Optional existingClient = patClientRepo.findByCreatedByUsername( + user.getUsername()); + + if (existingClient.isPresent()) { + return existingClient.get(); + } + + PersonalAccessTokenOAuthClient client = new PersonalAccessTokenOAuthClient(); + client.setName("Personal Access Token Client for " + user.getUsername()); + String clientSecret = UUID.randomUUID().toString(); + client.setClientSecret(bCryptPasswordEncoder.encode(clientSecret)); + + List authorities = new ArrayList<>(); + authorities.add(new SimpleGrantedAuthority(Role.TRUSTED_CLIENT)); + client.setAuthorities(authorities); + + client.setScope(new HashSet<>()); + client.getScope().add(OAuthScope.READ.getScope()); + + client.setCreatedBy(user); + + return patClientRepo.save(client); + } + + private OAuth2AccessToken createAccessToken() { + DefaultOAuth2AccessToken token = new DefaultOAuth2AccessToken(UUID.randomUUID().toString()); + token.setScope(Sets.asSet(OAuthScope.READ.getScope())); + return token; + } + + + private String extractTokenKey(String value) { + if (value == null) { + return null; + } + MessageDigest digest; + try { + digest = MessageDigest.getInstance("MD5"); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("MD5 algorithm not available. Fatal (should be in the JDK)."); + } + + try { + byte[] bytes = digest.digest(value.getBytes("UTF-8")); + return String.format("%032x", new BigInteger(1, bytes)); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException("UTF-8 encoding not available. Fatal (should be in the JDK)."); + } + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/forms/UserAccessTokenForm.java b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/forms/UserAccessTokenForm.java new file mode 100644 index 000000000..347ad0015 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/forms/UserAccessTokenForm.java @@ -0,0 +1,20 @@ +package edu.asu.diging.citesphere.web.admin.forms; + +public class UserAccessTokenForm { + private String name; + private String redirectUrl; + + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + + public String getRedirectUrl() { + return redirectUrl; + } + public void setRedirectUrl(String callbackUrl) { + this.redirectUrl = callbackUrl; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/AccessTokenDetailsController.java b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/AccessTokenDetailsController.java new file mode 100644 index 000000000..be08e4afb --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/AccessTokenDetailsController.java @@ -0,0 +1,28 @@ +package edu.asu.diging.citesphere.web.admin.userOAuth; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import edu.asu.diging.citesphere.core.model.oauth.IPersonalAccessToken; +import edu.asu.diging.citesphere.core.service.oauth.IPersonalAccessTokenManager; + +@Controller +public class AccessTokenDetailsController { + + @Autowired + private IPersonalAccessTokenManager tokenManager; + + @RequestMapping(value="/admin/user/auth/accessTokens/{accessTokenId}", method=RequestMethod.GET) + public String showAppDetails(Model model, @PathVariable("accessTokenId") String accessTokenId) { + IPersonalAccessToken token = tokenManager.getTokenById(accessTokenId); + if (token != null) { + model.addAttribute("clientName", token.getName()); + model.addAttribute("clientId", accessTokenId); + } + return "admin/user/auth/details"; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/AddUserAccessTokenController.java b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/AddUserAccessTokenController.java new file mode 100644 index 000000000..b8f8aa757 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/AddUserAccessTokenController.java @@ -0,0 +1,43 @@ +package edu.asu.diging.citesphere.web.admin.userOAuth; + +import java.security.Principal; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +import edu.asu.diging.citesphere.core.service.oauth.IPersonalAccessTokenManager; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenCredentials; +import edu.asu.diging.citesphere.core.user.IUserManager; +import edu.asu.diging.citesphere.user.IUser; +import edu.asu.diging.citesphere.web.admin.forms.UserAccessTokenForm; + +@Controller +public class AddUserAccessTokenController { + + @Autowired + private IUserManager userManager; + + @Autowired + private IPersonalAccessTokenManager tokenManager; + + @RequestMapping(value="/admin/user/auth/accessTokens/add", method=RequestMethod.GET) + public String show(Model model) { + model.addAttribute("userAccessTokenForm", new UserAccessTokenForm()); + return "admin/user/auth/add"; + } + + @RequestMapping(value="/admin/user/auth/accessTokens/add", method=RequestMethod.POST) + public String add(@Validated UserAccessTokenForm userAccessTokenForm, Model model, BindingResult errors, RedirectAttributes redirectAttrs, Principal principal) { + IUser user = userManager.findByUsername(principal.getName()); + PersonalAccessTokenCredentials creds = tokenManager.createToken(userAccessTokenForm.getName(), user); + redirectAttrs.addFlashAttribute("clientId", creds.getTokenId()); + redirectAttrs.addFlashAttribute("secret", creds.getTokenValue()); + return "redirect:/admin/user/auth/accessTokens/" + creds.getTokenId(); + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/DeleteAccessTokenController.java b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/DeleteAccessTokenController.java new file mode 100644 index 000000000..21b1881c4 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/DeleteAccessTokenController.java @@ -0,0 +1,37 @@ +package edu.asu.diging.citesphere.web.admin.userOAuth; + +import java.security.Principal; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import edu.asu.diging.citesphere.core.exceptions.CannotFindTokenException; +import edu.asu.diging.citesphere.core.service.oauth.IPersonalAccessTokenManager; +import edu.asu.diging.citesphere.core.user.IUserManager; +import edu.asu.diging.citesphere.user.IUser; + +@Controller +public class DeleteAccessTokenController { + + @Autowired + private IUserManager userManager; + + @Autowired + private IPersonalAccessTokenManager tokenManager; + + @RequestMapping(value = "/admin/user/auth/accessTokens/{accessTokenId}", method = RequestMethod.DELETE) + public ResponseEntity deleteApp(@PathVariable("accessTokenId") String accessTokenId, Principal principal) { + IUser user = userManager.findByUsername(principal.getName()); + try { + tokenManager.deleteToken(accessTokenId, user); + return new ResponseEntity<>(HttpStatus.OK); + } catch (CannotFindTokenException e) { + return new ResponseEntity<>(HttpStatus.NOT_FOUND); + } + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/ShowAccessTokensController.java b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/ShowAccessTokensController.java new file mode 100644 index 000000000..207981aea --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/ShowAccessTokensController.java @@ -0,0 +1,35 @@ +package edu.asu.diging.citesphere.web.admin.userOAuth; + +import java.security.Principal; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; + +import edu.asu.diging.citesphere.core.service.oauth.IPersonalAccessTokenManager; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenResultPage; +import edu.asu.diging.citesphere.core.user.IUserManager; +import edu.asu.diging.citesphere.user.IUser; + +@Controller +public class ShowAccessTokensController { + + @Autowired + private IUserManager userManager; + + @Autowired + private IPersonalAccessTokenManager tokenManager; + + @RequestMapping(value="/admin/user/auth/accessTokens", method=RequestMethod.GET) + public String showAllApps(Model model, Pageable pageable, Principal principal) { + IUser user = userManager.findByUsername(principal.getName()); + PersonalAccessTokenResultPage result = tokenManager.getTokensForUser(user, pageable); + model.addAttribute("tokenList", result.getTokens()); + model.addAttribute("currentPage", pageable.getPageNumber()+1); + model.addAttribute("totalPages", result.getTotalPages()); + return "admin/user/auth/show"; + } +} diff --git a/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/UpdateAccessTokenController.java b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/UpdateAccessTokenController.java new file mode 100644 index 000000000..85cbcc296 --- /dev/null +++ b/citesphere/src/main/java/edu/asu/diging/citesphere/web/admin/userOAuth/UpdateAccessTokenController.java @@ -0,0 +1,33 @@ +package edu.asu.diging.citesphere.web.admin.userOAuth; + +import java.security.Principal; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; + +import edu.asu.diging.citesphere.core.exceptions.CannotFindTokenException; +import edu.asu.diging.citesphere.core.service.oauth.IPersonalAccessTokenManager; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenCredentials; +import edu.asu.diging.citesphere.core.user.IUserManager; +import edu.asu.diging.citesphere.user.IUser; + +@Controller +public class UpdateAccessTokenController { + + @Autowired + private IUserManager userManager; + + @Autowired + private IPersonalAccessTokenManager tokenManager; + + @RequestMapping(value="/admin/user/auth/accessTokens/{accessTokenId}/secret/update", method=RequestMethod.POST) + public @ResponseBody PersonalAccessTokenCredentials regenerateToken(Model model, @PathVariable("accessTokenId") String accessTokenId, Principal principal) throws CannotFindTokenException { + IUser user = userManager.findByUsername(principal.getName()); + return tokenManager.regenerateToken(accessTokenId, user); + } +} diff --git a/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/add.html b/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/add.html new file mode 100644 index 000000000..e07d7619e --- /dev/null +++ b/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/add.html @@ -0,0 +1,15 @@ + + +

Add User Access Token

+
+
+ + + + +

+ + + +
+
diff --git a/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/details.html b/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/details.html new file mode 100644 index 000000000..01927e710 --- /dev/null +++ b/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/details.html @@ -0,0 +1,103 @@ + + + + + + + +
+
+
+
+
+    +
+ +
+

+
+ + + + + + + + + + + + + + + + + + + +
Client Id
Token*Secret Key is hidden*
Name
+ + +
+ + diff --git a/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/show.html b/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/show.html new file mode 100644 index 000000000..88e849c2e --- /dev/null +++ b/citesphere/src/main/webapp/WEB-INF/views/admin/user/auth/show.html @@ -0,0 +1,114 @@ + + + + + + + + + + + +
+

Tokens

+
    + + + + + + + + + + + +
    Client IdName + +
    + + +
    + + diff --git a/citesphere/src/main/webapp/WEB-INF/views/layouts/main.html b/citesphere/src/main/webapp/WEB-INF/views/layouts/main.html index db0d1104e..d6c648967 100644 --- a/citesphere/src/main/webapp/WEB-INF/views/layouts/main.html +++ b/citesphere/src/main/webapp/WEB-INF/views/layouts/main.html @@ -67,6 +67,8 @@
  • clientList = new ArrayList<>(); @@ -72,7 +72,7 @@ public void test_getClientsDetails_clientNotFound() { Mockito.when(clientRepo.findAllById(clientList)).thenReturn(new ArrayList<>()); Assert.assertEquals(0, managerToTest.getClientsDetails(clientList).size()); } - + @Test public void test_getClientsDetails_fewClientsNotFound() { List clientList = new ArrayList<>(); @@ -86,7 +86,7 @@ public void test_getClientsDetails_fewClientsNotFound() { Assert.assertEquals(1, managerToTest.getClientsDetails(clientList).size()); Assert.assertEquals(client1, managerToTest.getClientsDetails(clientList).get(0)); } - + @Test public void test_getClientsDetails_emptyList() { Assert.assertEquals(0, managerToTest.getClientsDetails(new ArrayList<>()).size()); @@ -104,10 +104,9 @@ public void test_getAllApps() { Assert.assertEquals(clientList.size(), apps.size()); clientList.forEach(app -> Assert.assertTrue(apps.contains(app))); } - + @Test public void test_getAllApps_emptyList() { Assert.assertEquals(0, managerToTest.getAllApps().size()); } - } diff --git a/citesphere/src/test/java/edu/asu/diging/citesphere/core/service/oauth/impl/PersonalAccessTokenManagerTest.java b/citesphere/src/test/java/edu/asu/diging/citesphere/core/service/oauth/impl/PersonalAccessTokenManagerTest.java new file mode 100644 index 000000000..ef44151bb --- /dev/null +++ b/citesphere/src/test/java/edu/asu/diging/citesphere/core/service/oauth/impl/PersonalAccessTokenManagerTest.java @@ -0,0 +1,170 @@ +package edu.asu.diging.citesphere.core.service.oauth.impl; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import org.junit.Assert; +import org.junit.Before; +import org.junit.Test; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.Mockito; +import org.mockito.MockitoAnnotations; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; + +import edu.asu.diging.citesphere.core.exceptions.CannotFindTokenException; +import edu.asu.diging.citesphere.core.model.oauth.IPersonalAccessToken; +import edu.asu.diging.citesphere.core.model.oauth.impl.DbAccessToken; +import edu.asu.diging.citesphere.core.model.oauth.impl.PersonalAccessTokenOAuthClient; +import edu.asu.diging.citesphere.core.repository.oauth.DbAccessTokenRepository; +import edu.asu.diging.citesphere.core.repository.oauth.PersonalAccessTokenOAuthClientRepository; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenCredentials; +import edu.asu.diging.citesphere.core.service.oauth.PersonalAccessTokenResultPage; +import edu.asu.diging.citesphere.user.IUser; +import edu.asu.diging.citesphere.user.impl.User; + +public class PersonalAccessTokenManagerTest { + + @Mock + private PersonalAccessTokenOAuthClientRepository patClientRepo; + + @Mock + private DbAccessTokenRepository accessTokenRepo; + + @Mock + private BCryptPasswordEncoder bCryptPasswordEncoder; + + @InjectMocks + private PersonalAccessTokenManager managerToTest; + + private IUser testUser; + + @Before + public void setUp() { + MockitoAnnotations.initMocks(this); + testUser = new User(); + testUser.setUsername("testuser"); + } + + @Test + public void test_getTokensForUser_success() { + Pageable pageable = PageRequest.of(0, 10); + + List mockTokenList = new ArrayList<>(); + DbAccessToken token = new DbAccessToken(); + token.setId("token1"); + token.setName("Test Token"); + token.setUsername("testuser"); + token.setPersonalAccessToken(true); + mockTokenList.add(token); + + Page mockPage = new PageImpl<>(mockTokenList); + + Mockito.when(accessTokenRepo.findByUsernameAndPersonalAccessToken( + "testuser", true, pageable)) + .thenReturn(mockPage); + + PersonalAccessTokenResultPage resultPage = managerToTest.getTokensForUser(testUser, pageable); + + Assert.assertEquals(1, resultPage.getTokens().size()); + Assert.assertEquals("Test Token", resultPage.getTokens().get(0).getName()); + } + + @Test + public void test_getTokensForUser_emptyList() { + Pageable pageable = PageRequest.of(0, 10); + + List mockTokenList = new ArrayList<>(); + Page mockPage = new PageImpl<>(mockTokenList); + + Mockito.when(accessTokenRepo.findByUsernameAndPersonalAccessToken( + "testuser", true, pageable)) + .thenReturn(mockPage); + + PersonalAccessTokenResultPage resultPage = managerToTest.getTokensForUser(testUser, pageable); + + Assert.assertEquals(0, resultPage.getTokens().size()); + } + + @Test + public void test_getTokenById_success() { + DbAccessToken token = new DbAccessToken(); + token.setId("token1"); + token.setName("Test Token"); + token.setPersonalAccessToken(true); + + Mockito.when(accessTokenRepo.findById("token1")) + .thenReturn(Optional.of(token)); + + IPersonalAccessToken result = managerToTest.getTokenById("token1"); + + Assert.assertNotNull(result); + Assert.assertEquals("Test Token", result.getName()); + } + + @Test + public void test_getTokenById_notFound() { + Mockito.when(accessTokenRepo.findById("nonexistent")) + .thenReturn(Optional.empty()); + + IPersonalAccessToken result = managerToTest.getTokenById("nonexistent"); + + Assert.assertNull(result); + } + + @Test + public void test_getTokenById_notPersonalAccessToken() { + DbAccessToken token = new DbAccessToken(); + token.setId("token1"); + token.setName("Test Token"); + token.setPersonalAccessToken(false); + + Mockito.when(accessTokenRepo.findById("token1")) + .thenReturn(Optional.of(token)); + + IPersonalAccessToken result = managerToTest.getTokenById("token1"); + + Assert.assertNull(result); + } + + @Test + public void test_deleteToken_success() throws CannotFindTokenException { + DbAccessToken token = new DbAccessToken(); + token.setId("token1"); + token.setUsername("testuser"); + token.setPersonalAccessToken(true); + + Mockito.when(accessTokenRepo.findByIdAndUsername("token1", "testuser")) + .thenReturn(Optional.of(token)); + + managerToTest.deleteToken("token1", testUser); + + Mockito.verify(accessTokenRepo).delete(token); + } + + @Test(expected = CannotFindTokenException.class) + public void test_deleteToken_notFound() throws CannotFindTokenException { + Mockito.when(accessTokenRepo.findByIdAndUsername("token1", "testuser")) + .thenReturn(Optional.empty()); + + managerToTest.deleteToken("token1", testUser); + } + + @Test(expected = CannotFindTokenException.class) + public void test_deleteToken_notPersonalAccessToken() throws CannotFindTokenException { + DbAccessToken token = new DbAccessToken(); + token.setId("token1"); + token.setUsername("testuser"); + token.setPersonalAccessToken(false); + + Mockito.when(accessTokenRepo.findByIdAndUsername("token1", "testuser")) + .thenReturn(Optional.of(token)); + + managerToTest.deleteToken("token1", testUser); + } +}