before_1st_task_pr #5

Merged
deenath merged 14 commits from community/device-mgt-core:master into master 2 years ago

@ -40,11 +40,11 @@ public interface KeyManagerService {
@Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path("/token") @Path("/token")
Response generateAccessToken(@HeaderParam("Authorization") String basicAuthHeader, Response generateAccessToken(@HeaderParam("Authorization") String basicAuthHeader,
@FormParam("client_id") String clientId,
@FormParam("client_secret") String clientSecret,
@FormParam("refresh_token") String refreshToken, @FormParam("refresh_token") String refreshToken,
@FormParam("scope") String scope, @FormParam("scope") String scope,
@FormParam("grant_type") String grantType, @FormParam("grant_type") String grantType,
@FormParam("assertion") String assertion, @FormParam("assertion") String assertion,
@FormParam("admin_access_token") String admin_access_token); @FormParam("admin_access_token") String admin_access_token,
@FormParam("username") String username,
@FormParam("password") String password);
} }

@ -19,8 +19,6 @@
package org.wso2.carbon.apimgt.keymgt.extension.api; package org.wso2.carbon.apimgt.keymgt.extension.api;
import com.google.gson.Gson; import com.google.gson.Gson;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.wso2.carbon.apimgt.keymgt.extension.DCRResponse; import org.wso2.carbon.apimgt.keymgt.extension.DCRResponse;
import org.wso2.carbon.apimgt.keymgt.extension.TokenRequest; import org.wso2.carbon.apimgt.keymgt.extension.TokenRequest;
import org.wso2.carbon.apimgt.keymgt.extension.TokenResponse; import org.wso2.carbon.apimgt.keymgt.extension.TokenResponse;
@ -65,13 +63,13 @@ public class KeyManagerServiceImpl implements KeyManagerService {
@Consumes(MediaType.APPLICATION_FORM_URLENCODED) @Consumes(MediaType.APPLICATION_FORM_URLENCODED)
@Path("/token") @Path("/token")
public Response generateAccessToken(@HeaderParam("Authorization") String basicAuthHeader, public Response generateAccessToken(@HeaderParam("Authorization") String basicAuthHeader,
@FormParam("client_id") String clientId,
@FormParam("client_secret") String clientSecret,
@FormParam("refresh_token") String refreshToken, @FormParam("refresh_token") String refreshToken,
@FormParam("scope") String scope, @FormParam("scope") String scope,
@FormParam("grant_type") String grantType, @FormParam("grant_type") String grantType,
@FormParam("assertion") String assertion, @FormParam("assertion") String assertion,
@FormParam("admin_access_token") String admin_access_token) { @FormParam("admin_access_token") String admin_access_token,
@FormParam("username") String username,
@FormParam("password") String password) {
try { try {
if (basicAuthHeader == null) { if (basicAuthHeader == null) {
String msg = "Invalid credentials. Make sure your API call is invoked with a Basic Authorization header."; String msg = "Invalid credentials. Make sure your API call is invoked with a Basic Authorization header.";
@ -82,8 +80,8 @@ public class KeyManagerServiceImpl implements KeyManagerService {
TokenResponse resp = keyMgtService.generateAccessToken( TokenResponse resp = keyMgtService.generateAccessToken(
new TokenRequest(encodedClientCredentials.split(":")[0], new TokenRequest(encodedClientCredentials.split(":")[0],
encodedClientCredentials.split(":")[1], refreshToken, scope, encodedClientCredentials.split(":")[1], refreshToken, scope,
grantType, assertion,admin_access_token)); grantType, assertion, admin_access_token, username, password));
return Response.status(Response.Status.CREATED).entity(gson.toJson(resp)).build(); return Response.status(Response.Status.OK).entity(gson.toJson(resp)).build();
} catch (KeyMgtException e) { } catch (KeyMgtException e) {
return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build(); return Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).build();
} catch (BadRequestException e) { } catch (BadRequestException e) {

@ -26,9 +26,11 @@ public class TokenRequest {
private String grantType; private String grantType;
private String assertion; private String assertion;
private String admin_access_token; private String admin_access_token;
private String username;
private String password;
public TokenRequest(String clientId, String clientSecret, String refreshToken, String scope, String grantType, public TokenRequest(String clientId, String clientSecret, String refreshToken, String scope, String grantType,
String assertion, String admin_access_token) { String assertion, String admin_access_token, String username, String password) {
this.clientId = clientId; this.clientId = clientId;
this.clientSecret = clientSecret; this.clientSecret = clientSecret;
this.refreshToken = refreshToken; this.refreshToken = refreshToken;
@ -36,6 +38,8 @@ public class TokenRequest {
this.grantType = grantType; this.grantType = grantType;
this.assertion = assertion; this.assertion = assertion;
this.admin_access_token = admin_access_token; this.admin_access_token = admin_access_token;
this.username = username;
this.password = password;
} }
public String getClientId() { public String getClientId() {
@ -93,4 +97,20 @@ public class TokenRequest {
public void setAdminAccessToken(String admin_access_token) { public void setAdminAccessToken(String admin_access_token) {
this.admin_access_token = admin_access_token; this.admin_access_token = admin_access_token;
} }
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getPassword() {
return password;
}
public void setPassword(String password) {
this.password = password;
}
} }

@ -33,6 +33,13 @@ public class TokenResponse {
this.expires_in = expires_in; this.expires_in = expires_in;
} }
public TokenResponse(String access_token, String scope, String token_type, int expires_in) {
this.access_token = access_token;
this.scope = scope;
this.token_type = token_type;
this.expires_in = expires_in;
}
public String getAccessToken() { public String getAccessToken() {
return access_token; return access_token;
} }

@ -159,49 +159,34 @@ public class KeyMgtServiceImpl implements KeyMgtService {
} }
String tenantDomain = MultitenantUtils.getTenantDomain(application.getOwner()); String tenantDomain = MultitenantUtils.getTenantDomain(application.getOwner());
kmConfig = getKeyManagerConfig();
String username, password; String appTokenEndpoint = kmConfig.getServerUrl() + KeyMgtConstants.OAUTH2_TOKEN_ENDPOINT;
if (KeyMgtConstants.SUPER_TENANT.equals(tenantDomain)) {
kmConfig = getKeyManagerConfig();
username = kmConfig.getAdminUsername();
password = kmConfig.getAdminUsername();
} else {
try {
username = getRealmService()
.getTenantUserRealm(-1234).getRealmConfiguration()
.getRealmProperty("reserved_tenant_user_username") + "@" + tenantDomain;
password = getRealmService()
.getTenantUserRealm(-1234).getRealmConfiguration()
.getRealmProperty("reserved_tenant_user_password");
} catch (UserStoreException e) {
msg = "Error while loading user realm configuration";
log.error(msg);
throw new KeyMgtException(msg);
}
}
RequestBody appTokenPayload; RequestBody appTokenPayload;
switch (tokenRequest.getGrantType()) { switch (tokenRequest.getGrantType()) {
case "client_credentials": case "client_credentials":
appTokenPayload = new FormBody.Builder()
.add("grant_type", "client_credentials")
.add("scope", tokenRequest.getScope()).build();
break;
case "password": case "password":
appTokenPayload = new FormBody.Builder() appTokenPayload = new FormBody.Builder()
.add("grant_type", "password") .add("grant_type", "password")
.add("username", username) .add("username", tokenRequest.getUsername())
.add("password", password) .add("password", tokenRequest.getPassword())
.add("scope", tokenRequest.getScope()).build(); .add("scope", tokenRequest.getScope()).build();
break; break;
case "refresh_token": case "refresh_token":
appTokenPayload = new FormBody.Builder() appTokenPayload = new FormBody.Builder()
.add("grant_type", "refresh_token") .add("grant_type", "refresh_token")
.add("refresh_token", tokenRequest.getRefreshToken()) .add("refresh_token", tokenRequest.getRefreshToken()).build();
.add("scope", tokenRequest.getScope()).build();
break; break;
case "urn:ietf:params:oauth:grant-type:jwt-bearer": case "urn:ietf:params:oauth:grant-type:jwt-bearer":
appTokenPayload = new FormBody.Builder() appTokenPayload = new FormBody.Builder()
.add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer") .add("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")
.add("assertion", tokenRequest.getAssertion()) .add("assertion", tokenRequest.getAssertion())
.add("scope", tokenRequest.getScope()).build(); .add("scope", tokenRequest.getScope()).build();
appTokenEndpoint += "?tenantDomain=carbon.super";
break; break;
case "access_token": case "access_token":
appTokenPayload = new FormBody.Builder() appTokenPayload = new FormBody.Builder()
@ -216,8 +201,6 @@ public class KeyMgtServiceImpl implements KeyMgtService {
break; break;
} }
kmConfig = getKeyManagerConfig();
String appTokenEndpoint = kmConfig.getServerUrl() + KeyMgtConstants.OAUTH2_TOKEN_ENDPOINT;
Request request = new Request.Builder() Request request = new Request.Builder()
.url(appTokenEndpoint) .url(appTokenEndpoint)
.addHeader(KeyMgtConstants.AUTHORIZATION_HEADER, Credentials.basic(tokenRequest.getClientId(), tokenRequest.getClientSecret())) .addHeader(KeyMgtConstants.AUTHORIZATION_HEADER, Credentials.basic(tokenRequest.getClientId(), tokenRequest.getClientSecret()))
@ -239,12 +222,19 @@ public class KeyMgtServiceImpl implements KeyMgtService {
.getTenantManager().getTenantId(tenantDomain); .getTenantManager().getTenantId(tenantDomain);
accessToken = tenantId + "_" + responseObj.getString("access_token"); accessToken = tenantId + "_" + responseObj.getString("access_token");
} }
return new TokenResponse(accessToken,
responseObj.getString("refresh_token"),
responseObj.getString("scope"),
responseObj.getString("token_type"),
responseObj.getInt("expires_in"));
if (tokenRequest.getGrantType().equals("client_credentials")) {
return new TokenResponse(accessToken,
responseObj.getString("scope"),
responseObj.getString("token_type"),
responseObj.getInt("expires_in"));
} else {
return new TokenResponse(accessToken,
responseObj.getString("refresh_token"),
responseObj.getString("scope"),
responseObj.getString("token_type"),
responseObj.getInt("expires_in"));
}
} catch (APIManagementException e) { } catch (APIManagementException e) {
msg = "Error occurred while retrieving application"; msg = "Error occurred while retrieving application";
log.error(msg); log.error(msg);

@ -194,8 +194,8 @@ public class GroupManagementProviderServiceImpl implements GroupManagementProvid
GroupManagementDAOFactory.beginTransaction(); GroupManagementDAOFactory.beginTransaction();
DeviceGroup existingGroup = this.groupDAO.getGroup(groupId, tenantId); DeviceGroup existingGroup = this.groupDAO.getGroup(groupId, tenantId);
if (existingGroup != null) { if (existingGroup != null) {
boolean existingGroupName = this.groupDAO.getGroup(deviceGroup.getName(), tenantId) != null; DeviceGroup existingGroupByName = this.groupDAO.getGroup(deviceGroup.getName(), tenantId);
if (existingGroupName) { if (existingGroupByName != null && existingGroupByName.getGroupId() != groupId) {
throw new GroupAlreadyExistException("Group already exists with name '" + deviceGroup.getName() + "'."); throw new GroupAlreadyExistException("Group already exists with name '" + deviceGroup.getName() + "'.");
} }
List<DeviceGroup> groupsToUpdate = new ArrayList<>(); List<DeviceGroup> groupsToUpdate = new ArrayList<>();

@ -229,7 +229,7 @@ public class LoginHandler extends HttpServlet {
* @throws IOException IO exception throws if an error occurred when invoking token endpoint * @throws IOException IO exception throws if an error occurred when invoking token endpoint
*/ */
private ProxyResponse getTokenResult(String encodedClientApp, JsonArray scopes) throws IOException { private ProxyResponse getTokenResult(String encodedClientApp, JsonArray scopes) throws IOException {
HttpPost tokenEndpoint = new HttpPost(kmManagerUrl+ HandlerConstants.TOKEN_ENDPOINT); HttpPost tokenEndpoint = new HttpPost(gatewayUrl + HandlerConstants.INTERNAL_TOKEN_ENDPOINT);
tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + encodedClientApp); tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + encodedClientApp);
tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString()); tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString());
String scopeString = HandlerUtil.getScopeString(scopes); String scopeString = HandlerUtil.getScopeString(scopes);

@ -68,7 +68,7 @@ public class SsoLoginCallbackHandler extends HttpServlet {
String scope = session.getAttribute("scope").toString(); String scope = session.getAttribute("scope").toString();
HttpPost tokenEndpoint = new HttpPost(keyManagerUrl + HandlerConstants.TOKEN_ENDPOINT); HttpPost tokenEndpoint = new HttpPost(keyManagerUrl + HandlerConstants.OAUTH2_TOKEN_ENDPOINT);
tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + session.getAttribute("encodedClientApp")); tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + session.getAttribute("encodedClientApp"));
tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString()); tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString());

@ -325,7 +325,7 @@ public class SsoLoginHandler extends HttpServlet {
* @throws IOException IO exception throws if an error occurred when invoking token endpoint * @throws IOException IO exception throws if an error occurred when invoking token endpoint
*/ */
private ProxyResponse getTokenResult(String encodedClientApp) throws IOException { private ProxyResponse getTokenResult(String encodedClientApp) throws IOException {
HttpPost tokenEndpoint = new HttpPost(keyManagerUrl + HandlerConstants.TOKEN_ENDPOINT); HttpPost tokenEndpoint = new HttpPost(keyManagerUrl + HandlerConstants.OAUTH2_TOKEN_ENDPOINT);
tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + encodedClientApp); tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + encodedClientApp);
tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString()); tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString());

@ -71,6 +71,7 @@ public class UserHandler extends HttpServlet {
} }
String accessToken = authData.getAccessToken(); String accessToken = authData.getAccessToken();
String accessTokenWithoutPrefix = accessToken.substring(accessToken.indexOf("_") + 1);
HttpPost tokenEndpoint = new HttpPost(keymanagerUrl + HandlerConstants.INTROSPECT_ENDPOINT); HttpPost tokenEndpoint = new HttpPost(keymanagerUrl + HandlerConstants.INTROSPECT_ENDPOINT);
tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString()); tokenEndpoint.setHeader(HttpHeaders.CONTENT_TYPE, ContentType.APPLICATION_FORM_URLENCODED.toString());
@ -79,7 +80,7 @@ public class UserHandler extends HttpServlet {
String adminPassword = dmc.getKeyManagerConfigurations().getAdminPassword(); String adminPassword = dmc.getKeyManagerConfigurations().getAdminPassword();
tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + Base64.getEncoder() tokenEndpoint.setHeader(HttpHeaders.AUTHORIZATION, HandlerConstants.BASIC + Base64.getEncoder()
.encodeToString((adminUsername + HandlerConstants.COLON + adminPassword).getBytes())); .encodeToString((adminUsername + HandlerConstants.COLON + adminPassword).getBytes()));
StringEntity tokenEPPayload = new StringEntity("token=" + accessToken, StringEntity tokenEPPayload = new StringEntity("token=" + accessTokenWithoutPrefix,
ContentType.APPLICATION_FORM_URLENCODED); ContentType.APPLICATION_FORM_URLENCODED);
tokenEndpoint.setEntity(tokenEPPayload); tokenEndpoint.setEntity(tokenEPPayload);
ProxyResponse tokenStatus = HandlerUtil.execute(tokenEndpoint); ProxyResponse tokenStatus = HandlerUtil.execute(tokenEndpoint);

@ -22,7 +22,8 @@ public class HandlerConstants {
public static final String PUBLISHER_APPLICATION_NAME = "application-mgt-publisher"; public static final String PUBLISHER_APPLICATION_NAME = "application-mgt-publisher";
public static final String APP_REG_ENDPOINT = "/api-application-registration/register"; public static final String APP_REG_ENDPOINT = "/api-application-registration/register";
public static final String UI_CONFIG_ENDPOINT = "/api/device-mgt-config/v1.0/configurations/ui-config"; public static final String UI_CONFIG_ENDPOINT = "/api/device-mgt-config/v1.0/configurations/ui-config";
public static final String TOKEN_ENDPOINT = "/oauth2/token"; public static final String OAUTH2_TOKEN_ENDPOINT = "/oauth2/token";
public static final String INTERNAL_TOKEN_ENDPOINT = "/token";
public static final String INTROSPECT_ENDPOINT = "/oauth2/introspect"; public static final String INTROSPECT_ENDPOINT = "/oauth2/introspect";
public static final String AUTHORIZATION_ENDPOINT = "/oauth2/authorize"; public static final String AUTHORIZATION_ENDPOINT = "/oauth2/authorize";
public static final String APIM_APPLICATIONS_ENDPOINT = "/api/am/devportal/v2/applications/"; public static final String APIM_APPLICATIONS_ENDPOINT = "/api/am/devportal/v2/applications/";

@ -55,7 +55,6 @@ import org.json.JSONException;
import org.json.JSONObject; import org.json.JSONObject;
import org.w3c.dom.Document; import org.w3c.dom.Document;
import io.entgra.ui.request.interceptor.beans.ProxyResponse; import io.entgra.ui.request.interceptor.beans.ProxyResponse;
import org.wso2.carbon.device.mgt.core.common.util.HttpUtil;
import org.xml.sax.SAXException; import org.xml.sax.SAXException;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
@ -654,7 +653,7 @@ public class HandlerUtil {
return tokenResultResponse; return tokenResultResponse;
} }
public static ProxyResponse getTokenResult(AuthData authData, String keymanagerUrl) throws IOException { public static ProxyResponse getTokenResult(AuthData authData, String keymanagerUrl) throws IOException {
HttpPost tokenEndpoint = new HttpPost(keymanagerUrl + HandlerConstants.TOKEN_ENDPOINT); HttpPost tokenEndpoint = new HttpPost(keymanagerUrl + HandlerConstants.OAUTH2_TOKEN_ENDPOINT);
StringEntity tokenEndpointPayload = new StringEntity( StringEntity tokenEndpointPayload = new StringEntity(
"grant_type=refresh_token&refresh_token=" + authData.getRefreshToken(), "grant_type=refresh_token&refresh_token=" + authData.getRefreshToken(),
ContentType.APPLICATION_FORM_URLENCODED); ContentType.APPLICATION_FORM_URLENCODED);
@ -735,4 +734,4 @@ public class HandlerUtil {
public static boolean isPropertyDefined(String property) { public static boolean isPropertyDefined(String property) {
return StringUtils.isEmpty(System.getProperty(property)); return StringUtils.isEmpty(System.getProperty(property));
} }
} }

@ -1,52 +1,52 @@
## Purpose ## Purpose
> Describe the problems, issues, or needs driving this feature/fix and include links to related issues in the following format: Resolves issue1, issue2, etc. > Describe the problems, issues, or needs driving this feature/fix and include links to related issues in the following format: Resolves issue1, issue2, etc.
## Goals ## Goals
> Describe the solutions that this feature/fix will introduce to resolve the problems described above > Describe the solutions that this feature/fix will introduce to resolve the problems described above
## Approach ## Approach
> Describe how you are implementing the solutions. Include an animated GIF or screenshot if the change affects the UI (email documentation@wso2.com to review all UI text). Include a link to a Markdown file or Google doc if the feature write-up is too long to paste here. > Describe how you are implementing the solutions. Include an animated GIF or screenshot if the change affects the UI (email content-group@entgra.io to review all UI text). Include a link to a Markdown file or Google doc if the feature write-up is too long to paste here.
## User stories ## User stories
> Summary of user stories addressed by this change> > Summary of user stories addressed by this change>
## Release note ## Release note
> Brief description of the new feature or bug fix as it will appear in the release notes > Brief description of the new feature or bug fix as it will appear in the release notes
## Documentation ## Documentation
> Link(s) to product documentation that addresses the changes of this PR. If no doc impact, enter “N/A” plus brief explanation of why theres no doc impact > Link(s) to product documentation that addresses the changes of this PR. If no doc impact, enter “N/A” plus brief explanation of why there's no doc impact
## Training ## Training
> Link to the PR for changes to the training content in https://github.com/wso2/WSO2-Training, if applicable > Link to the PR for changes to the training content in https://github.com/wso2/WSO2-Training, if applicable
## Certification ## Certification
> Type “Sent” when you have provided new/updated certification questions, plus four answers for each question (correct answer highlighted in bold), based on this change. Certification questions/answers should be sent to certification@wso2.com and NOT pasted in this PR. If there is no impact on certification exams, type “N/A” and explain why. > Type “Sent” when you have provided new/updated certification questions, plus four answers for each question (correct answer highlighted in bold), based on this change. Certification questions/answers should be sent to certification@wso2.com and NOT pasted in this PR. If there is no impact on certification exams, type “N/A” and explain why.
## Marketing ## Marketing
> Link to drafts of marketing content that will describe and promote this feature, including product page changes, technical articles, blog posts, videos, etc., if applicable > Link to drafts of marketing content that will describe and promote this feature, including product page changes, technical articles, blog posts, videos, etc., if applicable
## Automation tests ## Automation tests
- Unit tests - Unit tests
> Code coverage information > Code coverage information
- Integration tests - Integration tests
> Details about the test cases and coverage > Details about the test cases and coverage
## Security checks ## Security checks
- Followed secure coding standards in http://wso2.com/technical-reports/wso2-secure-engineering-guidelines? yes/no - Followed secure coding standards in http://wso2.com/technical-reports/wso2-secure-engineering-guidelines? yes/no
- Ran FindSecurityBugs plugin and verified report? yes/no - Ran FindSecurityBugs plugin and verified report? yes/no
- Confirmed that this PR doesn't commit any keys, passwords, tokens, usernames, or other secrets? yes/no - Confirmed that this PR doesn't commit any keys, passwords, tokens, usernames, or other secrets? yes/no
## Samples ## Samples
> Provide high-level details about the samples related to this feature > Provide high-level details about the samples related to this feature
## Related PRs ## Related PRs
> List any other related PRs > List any other related PRs
## Migrations (if applicable) ## Migrations (if applicable)
> Describe migration steps and platforms on which migration has been tested > Describe migration steps and platforms on which migration has been tested
## Test environment ## Test environment
> List all JDK versions, operating systems, databases, and browser/versions on which this feature/fix was tested > List all JDK versions, operating systems, databases, and browser/versions on which this feature/fix was tested
## Learning ## Learning
> Describe the research phase and any blog posts, patterns, libraries, or add-ons you used to solve the problem. > Describe the research phase and any blog posts, patterns, libraries, or add-ons you used to solve the problem.
Loading…
Cancel
Save