Skip to content

Commit

Permalink
Feat(User): Create new endpoints to Create/Revoke/List rest api token
Browse files Browse the repository at this point in the history
Signed-off-by: Nguyen Hoang <[email protected]>
  • Loading branch information
hoangnt2 committed Jan 2, 2024
1 parent 590a2b3 commit 674c380
Show file tree
Hide file tree
Showing 6 changed files with 305 additions and 1 deletion.
39 changes: 38 additions & 1 deletion rest/resource-server/src/docs/asciidoc/users.adoc
Original file line number Diff line number Diff line change
Expand Up @@ -68,4 +68,41 @@ include::{snippets}/should_document_create_user/response-fields.adoc[]
include::{snippets}/should_document_create_user/curl-request.adoc[]

===== Example response
include::{snippets}/should_document_create_user/http-response.adoc[]
include::{snippets}/should_document_create_user/http-response.adoc[]

[[resources-user-token-get]]
==== List all of rest api tokens of current user.

A `GET` request will list all of rest api tokens of current user.

===== Response structure
include::{snippets}/should_document_list_all_user_tokens/response-fields.adoc[]

===== Example request
include::{snippets}/should_document_list_all_user_tokens/curl-request.adoc[]

===== Example response
include::{snippets}/should_document_list_all_user_tokens/http-response.adoc[]


[[resources-user-token-create]]
==== Create a new rest api token for current user.

A `POST` request will create a new rest api token for current user.

===== Example request
include::{snippets}/should_document_create_user_token/curl-request.adoc[]

===== Example response
include::{snippets}/should_document_create_user_token/http-response.adoc[]

[[resources-user-token-delete]]
==== Delete rest api token for current user by token's name.

A `DELETE` request will delete rest api token for current user by token's name

===== Example request
include::{snippets}/should_document_revoke_user_token/curl-request.adoc[]

===== Example response
include::{snippets}/should_document_revoke_user_token/http-response.adoc[]
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ public class Sw360ResourceServer extends SpringBootServletInitializer {
public static final String API_TOKEN_HASH_SALT;
public static final String API_TOKEN_MAX_VALIDITY_READ_IN_DAYS;
public static final String API_TOKEN_MAX_VALIDITY_WRITE_IN_DAYS;
public static final UserGroup API_WRITE_ACCESS_USERGROUP;
public static final Set<String> DOMAIN;
public static final String REPORT_FILENAME_MAPPING;
public static final String JWKS_ISSUER_URL;
Expand All @@ -80,6 +81,7 @@ public class Sw360ResourceServer extends SpringBootServletInitializer {
API_TOKEN_MAX_VALIDITY_READ_IN_DAYS = props.getProperty("rest.apitoken.read.validity.days", "90");
API_TOKEN_MAX_VALIDITY_WRITE_IN_DAYS = props.getProperty("rest.apitoken.write.validity.days", "30");
API_TOKEN_HASH_SALT = props.getProperty("rest.apitoken.hash.salt", "$2a$04$Software360RestApiSalt");
API_WRITE_ACCESS_USERGROUP = UserGroup.valueOf(props.getProperty("rest.write.access.usergroup", UserGroup.ADMIN.name()));
DOMAIN = CommonUtils.splitToSet(props.getProperty("domain",
"Application Software, Documentation, Embedded Software, Hardware, Test and Diagnostics"));
REPORT_FILENAME_MAPPING = props.getProperty("org.eclipse.sw360.licensinfo.projectclearing.templatemapping", "");
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
import org.eclipse.sw360.datahandler.thrift.projects.ProjectType;
import org.eclipse.sw360.datahandler.thrift.projects.ProjectDTO;
import org.eclipse.sw360.datahandler.thrift.search.SearchResult;
import org.eclipse.sw360.datahandler.thrift.users.RestApiToken;
import org.eclipse.sw360.datahandler.thrift.users.User;
import org.eclipse.sw360.datahandler.thrift.vendors.Vendor;
import org.eclipse.sw360.datahandler.thrift.vulnerabilities.*;
Expand Down Expand Up @@ -117,6 +118,7 @@ public Sw360Module() {
setMixInAnnotation(EmbeddedProjectDTO.class, Sw360Module.EmbeddedProjectDTOMixin.class);
setMixInAnnotation(ReleaseNode.class, Sw360Module.ReleaseNodeMixin.class);
setMixInAnnotation(RestrictedResource.class, Sw360Module.RestrictedResourceMixin.class);
setMixInAnnotation(RestApiToken.class, Sw360Module.RestApiTokenMixin.class);

// Make spring doc aware of the mixin(s)
SpringDocUtils.getConfig()
Expand Down Expand Up @@ -2189,5 +2191,19 @@ public abstract static class ReleaseNodeMixin extends ReleaseNode {
})
public abstract static class RestrictedResourceMixin extends RestrictedResource {
}

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties({
"setName",
"setCreatedOn",
"setToken",
"setNumberOfDaysValid",
"authoritiesIterator",
"authoritiesSize",
"setAuthorities",
"token"
})
public abstract static class RestApiTokenMixin extends RestApiToken {
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,20 @@

package org.eclipse.sw360.rest.resourceserver.user;

import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.apache.thrift.TException;
import org.apache.thrift.protocol.TCompactProtocol;
import org.apache.thrift.protocol.TProtocol;
import org.apache.thrift.transport.THttpClient;
import org.apache.thrift.transport.TTransportException;
import org.eclipse.sw360.datahandler.common.CommonUtils;
import org.eclipse.sw360.datahandler.common.SW360Utils;
import org.eclipse.sw360.datahandler.permissions.PermissionUtils;
import org.eclipse.sw360.datahandler.thrift.AddDocumentRequestStatus;
import org.eclipse.sw360.datahandler.thrift.AddDocumentRequestSummary;
import org.eclipse.sw360.datahandler.thrift.SW360Exception;
import org.eclipse.sw360.datahandler.thrift.users.RestApiToken;
import org.eclipse.sw360.datahandler.thrift.users.User;
import org.eclipse.sw360.datahandler.thrift.users.UserGroup;
import org.eclipse.sw360.datahandler.thrift.users.UserService;
Expand All @@ -26,12 +32,24 @@
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.stereotype.Service;

import java.time.LocalDate;
import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Set;
import java.util.Map;
import java.util.stream.Collectors;

import static org.eclipse.sw360.rest.resourceserver.Sw360ResourceServer.API_TOKEN_MAX_VALIDITY_READ_IN_DAYS;
import static org.eclipse.sw360.rest.resourceserver.Sw360ResourceServer.API_TOKEN_MAX_VALIDITY_WRITE_IN_DAYS;
import static org.eclipse.sw360.rest.resourceserver.Sw360ResourceServer.API_WRITE_ACCESS_USERGROUP;

@Service
public class Sw360UserService {
@Value("${sw360.thrift-server-url:http://localhost:8080}")
private String thriftServerUrl;
private static final String AUTHORITIES_READ = "READ";
private static final String AUTHORITIES_WRITE = "WRITE";
private static final String EXPIRATION_DATE_PROPERTY = "expirationDate";

public List<User> getAllUsers() {
try {
Expand Down Expand Up @@ -114,4 +132,80 @@ private UserService.Iface getThriftUserClient() throws TTransportException {
TProtocol protocol = new TCompactProtocol(thriftClient);
return new UserService.Client(protocol);
}

public void updateUser(User sw360User) throws TException {
UserService.Iface sw360UserClient = getThriftUserClient();
sw360UserClient.updateUser(sw360User);
}

public RestApiToken convertToRestApiToken(Map<String, Object> requestBody, User sw360User) {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);

if (!requestBody.containsKey(EXPIRATION_DATE_PROPERTY)) {
throw new IllegalArgumentException("expirationDate is a required field.");
}
if (!(requestBody.get(EXPIRATION_DATE_PROPERTY) instanceof String)) {
throw new IllegalArgumentException("expirationDate must be a string.");
}

RestApiToken restApiToken = mapper.convertValue(requestBody, RestApiToken.class);
int numberOfExpireDay = getNumberOfExpireDays(requestBody.get(EXPIRATION_DATE_PROPERTY).toString());
restApiToken.setNumberOfDaysValid(numberOfExpireDay);
validateRestApiToken(restApiToken, sw360User);
restApiToken.setCreatedOn(SW360Utils.getCreatedOnTime());


return restApiToken;
}

private int getNumberOfExpireDays(String requestExpirationDate) {
LocalDate expirationDate = LocalDate.parse(requestExpirationDate);
return (int) ChronoUnit.DAYS.between(LocalDate.now(), expirationDate);
}

public boolean isTokenNameExisted(User user, String tokenName) {
return CommonUtils.nullToEmptyList(user.getRestApiTokens()).stream().anyMatch(t -> t.getName().equals(tokenName));
}

private boolean isValidExpireDays(RestApiToken restApiToken) {
String configExpireDays = restApiToken.getAuthorities().contains(AUTHORITIES_WRITE) ?
API_TOKEN_MAX_VALIDITY_WRITE_IN_DAYS : API_TOKEN_MAX_VALIDITY_READ_IN_DAYS;

try {
return restApiToken.getNumberOfDaysValid() >= 0 &&
restApiToken.getNumberOfDaysValid() <= Integer.parseInt(configExpireDays);
} catch (NumberFormatException e) {
return false;
}
}

private void validateRestApiToken(RestApiToken restApiToken, User sw360User) {
if (isTokenNameExisted(sw360User, restApiToken.getName())) {
throw new IllegalArgumentException("Duplicate token name.");
}

if (!restApiToken.getAuthorities().contains(AUTHORITIES_READ)) {
throw new IllegalArgumentException("READ permission is required.");
}


if (restApiToken.getAuthorities().contains(AUTHORITIES_WRITE)) {
// User needs at least the role which is defined in sw360.properties (default admin)
if (!PermissionUtils.isUserAtLeast(API_WRITE_ACCESS_USERGROUP, sw360User))
throw new IllegalArgumentException("User permission [WRITE] is not allowed for user");
if (!isValidExpireDays(restApiToken)) {
throw new IllegalArgumentException("Token expiration days is not valid for user");
}
}

// Only READ and WRITE permission is allowed
Set<String> otherPermissions = restApiToken.getAuthorities()
.stream()
.filter(permission -> !permission.equals(AUTHORITIES_READ) && !permission.equals(AUTHORITIES_WRITE))
.collect(Collectors.toSet());
if (!otherPermissions.isEmpty()) {
throw new IllegalArgumentException("Invalid permissions: " + String.join(", ", otherPermissions) + ".");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,21 @@
package org.eclipse.sw360.rest.resourceserver.user;

import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.Operation;
import lombok.NonNull;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

import org.apache.commons.lang.RandomStringUtils;
import org.apache.thrift.TException;
import org.eclipse.sw360.datahandler.common.CommonUtils;
import org.eclipse.sw360.datahandler.common.SW360Constants;
import org.eclipse.sw360.datahandler.resourcelists.ResourceClassNotFoundException;
import org.eclipse.sw360.datahandler.resourcelists.PaginationParameterException;
import org.eclipse.sw360.datahandler.resourcelists.PaginationResult;
import org.eclipse.sw360.datahandler.thrift.users.RestApiToken;
import org.eclipse.sw360.datahandler.thrift.users.User;
import org.eclipse.sw360.rest.resourceserver.core.HalResource;
import org.eclipse.sw360.rest.resourceserver.core.RestControllerHelper;
Expand All @@ -35,6 +38,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.security.crypto.bcrypt.BCrypt;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.servlet.support.ServletUriComponentsBuilder;
Expand All @@ -46,8 +50,12 @@
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;

import static org.eclipse.sw360.rest.resourceserver.Sw360ResourceServer.API_TOKEN_HASH_SALT;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;

@BasePathAwareController
Expand Down Expand Up @@ -165,6 +173,77 @@ public ResponseEntity<EntityModel<User>> createUser(
return ResponseEntity.created(location).body(halResource);
}

@Operation(
summary = "List all of rest api tokens.",
description = "List all of rest api tokens of current user.",
responses = {
@ApiResponse(responseCode = "200", description = "List of tokens.")
},
tags = {"Users"}
)
@RequestMapping(value = USERS_URL + "/tokens", method = RequestMethod.GET)
public ResponseEntity<CollectionModel<EntityModel<RestApiToken>>> getUserRestApiTokens() {
final User sw360User = restControllerHelper.getSw360UserFromAuthentication();
List<RestApiToken> restApiTokens = sw360User.getRestApiTokens();

if (restApiTokens == null) {
return new ResponseEntity<>(CollectionModel.of(Collections.emptyList()), HttpStatus.OK);
}

List<EntityModel<RestApiToken>> restApiResources = restApiTokens.stream()
.map(EntityModel::of)
.collect(Collectors.toList());
return new ResponseEntity<>(CollectionModel.of(restApiResources), HttpStatus.OK);
}

@Operation(
summary = "Create rest api token.",
description = "Create rest api token for current user.",
responses = {
@ApiResponse(responseCode = "201", description = "Create token successfully."),
@ApiResponse(responseCode = "500", description = "Create token failure.")
},
tags = {"Users"}
)
@RequestMapping(value = USERS_URL + "/tokens", method = RequestMethod.POST)
public ResponseEntity<String> createUserRestApiToken(
@RequestBody Map<String, Object> requestBody
) throws TException {
User sw360User = restControllerHelper.getSw360UserFromAuthentication();
RestApiToken restApiToken = userService.convertToRestApiToken(requestBody, sw360User);
String token = RandomStringUtils.random(20, true, true);
restApiToken.setToken(BCrypt.hashpw(token, API_TOKEN_HASH_SALT));
sw360User.addToRestApiTokens(restApiToken);
userService.updateUser(sw360User);

return new ResponseEntity<>(token, HttpStatus.CREATED);
}

@Operation(
summary = "Delete rest api token.",
description = "Delete rest api token by name for current user.",
responses = {
@ApiResponse(responseCode = "204", description = "Revoke token successfully."),
@ApiResponse(responseCode = "404", description = "Token name not found.")
},
tags = {"Users"}
)
@RequestMapping(value = USERS_URL + "/tokens", method = RequestMethod.DELETE)
public ResponseEntity<String> revokeUserRestApiToken(
@Parameter(description = "Name of token to be revoked.", example = "MyToken")
@RequestParam("name") String tokenName
) throws TException {
User sw360User = restControllerHelper.getSw360UserFromAuthentication();

if (!userService.isTokenNameExisted(sw360User, tokenName)) {
return new ResponseEntity<>("Token not found: " + tokenName, HttpStatus.NOT_FOUND);
}

sw360User.getRestApiTokens().removeIf(t -> t.getName().equals(tokenName));
userService.updateUser(sw360User);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}

@Override
public RepositoryLinksResource process(RepositoryLinksResource resource) {
resource.add(linkTo(UserController.class).slash("api/users").withRel("users"));
Expand Down
Loading

0 comments on commit 674c380

Please sign in to comment.