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 + + + + + Access Token Name: + + + + Add + + + 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 @@ + + + + + + + + + + Show All Tokens + + + Regenerate Token + + + + + + + + Client Id + + + + Token + + + + + *Secret Key is hidden* + + + + + Name + + + + + + + + × + Regenerate Token + + + Once a new token has been generated, using the old secret won't be able to access API's anymore. Are you sure you want to regenerate the token? + + + + + + + + + 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 + + Add Token + + + Client Id + Name + + + + + + + + + + + + + + × + Delete User Access Token + + + Are you sure you want to delete access token with client Id ? + + + + + + + + + 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 @@ See all Apps + See + all Access Tokens Add 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); + } +}
Once a new token has been generated, using the old secret won't be able to access API's anymore. Are you sure you want to regenerate the token?
Are you sure you want to delete access token with client Id ?