Commit d56b408d authored by Matija Obreza's avatar Matija Obreza

Merge branch '395-google-sign-in-authentication-system' into 'master'

Resolve "Google Sign-in authentication system"

Closes #395

See merge request genesys-pgr/genesys-server!319
parents c1c755f1 0c73b57c
......@@ -76,7 +76,6 @@
<spring.security.oauth2.version>2.3.3.RELEASE</spring.security.oauth2.version>
<spring-security-jwt>1.0.8.RELEASE</spring-security-jwt>
<org.springframework.social-version>1.1.4.RELEASE</org.springframework.social-version>
<org.springframework.social-google-version>1.0.0.RELEASE</org.springframework.social-google-version>
<querydsl.version>4.1.4</querydsl.version>
<hibernate.version>4.3.11.Final</hibernate.version>
......@@ -560,23 +559,16 @@
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-google</artifactId>
<version>${org.springframework.social-google-version}</version>
<groupId>com.google.api-client</groupId>
<artifactId>google-api-client</artifactId>
<version>1.27.0</version>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-web</artifactId>
<version>${org.springframework.social-version}</version>
</dependency>
<dependency>
<groupId>org.springframework.social</groupId>
<artifactId>spring-social-core</artifactId>
<version>${org.springframework.social-version}</version>
</dependency>
<!--Test dependencies -->
<dependency>
<groupId>org.hamcrest</groupId>
......
......@@ -254,7 +254,7 @@ public class DatasetServiceImpl implements DatasetService {
}
long deleted = accessionRefRepository.deleteForDataset(dataset);
System.err.println("Deleted " + deleted + " refs for dataset");
LOG.trace("Deleted {} refs for dataset", deleted);
return addAccessionRefs(dataset, accessionRefs);
}
......@@ -614,33 +614,27 @@ public class DatasetServiceImpl implements DatasetService {
accessionRefRepository.save(dArs);
loadedDataset.setAccessionCount((int) accessionRefRepository.countByDataset(loadedDataset));
LOG.warn("Done saving {} accession refs, have {} in dataset", accessionRefs.size(), loadedDataset.getAccessionCount());
threadPoolTaskExecutor.execute(() -> {
try {
Thread.sleep(2000);
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
// explicitly setting the transaction name is something that can only be done
// programmatically
def.setName("SomeTxName");
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
datasetRepository.save(loadedDataset);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// execute your business logic here
rematchDatasetAccessions(dArs);
} catch (Throwable ex) {
LOG.error("Rolling back rematch. Exception: {}", ex.getMessage(), ex);
transactionManager.rollback(status);
throw ex;
}
transactionManager.commit(status);
threadPoolTaskExecutor.execute(() -> {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
// explicitly setting the transaction name is something that can only be done
// programmatically
def.setName("SomeTxName");
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
} catch (InterruptedException e) {
LOG.warn("Interrupted rematcher");
TransactionStatus status = transactionManager.getTransaction(def);
try {
// execute your business logic here
rematchDatasetAccessions(dArs);
} catch (Throwable ex) {
LOG.error("Rolling back rematch. Exception: {}", ex.getMessage(), ex);
transactionManager.rollback(status);
throw ex;
}
transactionManager.commit(status);
});
return lazyLoad(datasetRepository.save(loadedDataset));
return lazyLoad(loadedDataset);
}
/**
......
/**
* Copyright 2014 Global Crop Diversity Trust
/*
* Copyright 2018 Global Crop Diversity Trust
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
......@@ -12,28 +12,17 @@
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
*/
package org.genesys2.server.component.security;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.util.ArrayList;
import java.util.List;
import java.util.Arrays;
import java.util.Collections;
import java.util.UUID;
import javax.inject.Named;
import javax.servlet.http.HttpServletRequest;
import org.apache.http.HttpResponse;
import org.apache.http.NameValuePair;
import org.apache.http.client.entity.UrlEncodedFormEntity;
import org.apache.http.client.methods.HttpPost;
import org.apache.http.client.utils.URLEncodedUtils;
import org.apache.http.impl.client.CloseableHttpClient;
import org.apache.http.impl.client.HttpClientBuilder;
import org.apache.http.message.BasicNameValuePair;
import org.genesys.blocks.security.UserException;
import org.genesys.blocks.security.model.BasicUser;
import org.genesys2.server.model.impl.User;
......@@ -41,26 +30,27 @@ import org.genesys2.server.service.UserService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.social.google.api.plus.Person;
import org.springframework.stereotype.Component;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeRequestUrl;
import com.google.api.client.googleapis.auth.oauth2.GoogleAuthorizationCodeTokenRequest;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
@Component
public class GoogleOAuthUtil {
private static final Logger LOG = LoggerFactory.getLogger(GoogleOAuthUtil.class);
public static final String LOCAL_GOOGLEAUTH_PATH = "/google/auth";
private ObjectMapper mapper = new ObjectMapper();
@Value("${base.url}")
private String baseUrl;
......@@ -74,84 +64,50 @@ public class GoogleOAuthUtil {
private UserService userService;
@Autowired
@Named("authUserDetailsService")
@Qualifier("authUserDetailsService")
private UserDetailsService userDetailsService;
public String exchangeForAccessToken(HttpServletRequest request) throws IOException {
final CloseableHttpClient httpclient = HttpClientBuilder.create().build();
try {
final HttpPost httppost = new HttpPost("https://accounts.google.com/o/oauth2/token");
final List<NameValuePair> params = new ArrayList<>();
params.add(new BasicNameValuePair("code", request.getParameter("code")));
params.add(new BasicNameValuePair("client_id", googleApiClientId));
params.add(new BasicNameValuePair("client_secret", secret));
params.add(new BasicNameValuePair("redirect_uri", baseUrl + LOCAL_GOOGLEAUTH_PATH));
params.add(new BasicNameValuePair("grant_type", "authorization_code"));
params.add(new BasicNameValuePair("scope", ""));
httppost.setEntity(new UrlEncodedFormEntity(params, "UTF-8"));
final HttpResponse response = httpclient.execute(httppost);
final BufferedReader reader = new BufferedReader(new InputStreamReader(response.getEntity().getContent(), "UTF-8"));
final StringBuilder builder = new StringBuilder();
for (String line = null; (line = reader.readLine()) != null;) {
builder.append(line).append("\n");
}
JsonNode json = mapper.readTree(builder.toString());
return json.has("access_token") ? json.get("access_token").textValue() : null;
} finally {
httpclient.close();
}
public GoogleTokenResponse exchangeForAccessToken(HttpServletRequest request) throws IOException {
// Exchange auth code for access token
return new GoogleAuthorizationCodeTokenRequest(
new NetHttpTransport(),
JacksonFactory.getDefaultInstance(),
"https://www.googleapis.com/oauth2/v4/token",
googleApiClientId,
secret,
request.getParameter("code"),
baseUrl + LOCAL_GOOGLEAUTH_PATH).execute();
}
public String getAuthenticationUrl() {
// google.auth.url=https://accounts.google.com/o/oauth2/auth?redirect_uri=http://localhost:8080/g/auth&response_type=code&client_id=12345-hfp8qjfeqaefpitbc707uluuh8vq65k7.apps.googleusercontent.com&scope=https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fplus.login+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fplus.me+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.email+https%3A%2F%2Fwww.googleapis.com%2Fauth%2Fuserinfo.profile&approval_prompt=auto&access_type=online&include_granted_scopes=true
final List<NameValuePair> parameters = new ArrayList<NameValuePair>();
parameters.add(new BasicNameValuePair("redirect_uri", baseUrl + LOCAL_GOOGLEAUTH_PATH));
parameters.add(new BasicNameValuePair("response_type", "code"));
parameters.add(new BasicNameValuePair("client_id", googleApiClientId));
parameters.add(new BasicNameValuePair("approval_prompt", "auto"));
parameters.add(new BasicNameValuePair("access_type", "online"));
parameters.add(new BasicNameValuePair("include_granted_scopes", "true"));
// Google+
// "https://www.googleapis.com/auth/plus.login https://www.googleapis.com/auth/plus.me https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"));
// Only basic:
parameters.add(new BasicNameValuePair("scope", "https://www.googleapis.com/auth/userinfo.profile https://www.googleapis.com/auth/userinfo.email"));
final String query = URLEncodedUtils.format(parameters, "UTF-8");
return "https://accounts.google.com/o/oauth2/auth?" + query;
return new GoogleAuthorizationCodeRequestUrl(googleApiClientId, baseUrl + LOCAL_GOOGLEAUTH_PATH,
Arrays.asList("https://www.googleapis.com/auth/userinfo.email", "https://www.googleapis.com/auth/userinfo.profile"))
.setResponseTypes(Collections.singletonList("code"))
.setApprovalPrompt("auto").build();
}
public Authentication googleAuthentication(Person userInfo) {
public Authentication googleAuthentication(GoogleIdToken.Payload tokenPayload) {
try {
final UserDetails userDetails = userDetailsService.loadUserByUsername(userInfo.getAccountEmail());
final UserDetails userDetails = userDetailsService.loadUserByUsername(tokenPayload.getEmail());
if (!(userDetails.isEnabled() && userDetails.isAccountNonExpired() && userDetails.isAccountNonLocked() && userDetails.isCredentialsNonExpired())) {
LOG.warn("Google login canceled: Account currently not available: {}", userInfo.getAccountEmail());
LOG.warn("Google login canceled: Account currently not available: {}", tokenPayload.getEmail());
return null;
}
final Authentication authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities());
SecurityContextHolder.getContext().setAuthentication(authentication);
return authentication;
} catch (final UsernameNotFoundException e) {
LOG.warn("Authentication with Google+ failed: No such user {}", userInfo.getAccountEmail());
LOG.warn("Authentication with Google failed: No such user {}", tokenPayload.getEmail());
return null;
}
}
public User extractUserFromGoogleProfile(Person userInfo) throws UserException {
public User extractUserFromGoogleTokenPayload(GoogleIdToken.Payload tokenPayload) throws UserException {
User user = null;
try {
user = userService.getUserByEmail(userInfo.getAccountEmail());
if(user == null){
throw new UsernameNotFoundException("User not found");
}
user = (User) userDetailsService.loadUserByUsername(tokenPayload.getEmail());
if (user.getAccountType() == BasicUser.AccountType.LOCAL) {
// account exists, change to {@link LoginType#GOOGLE}
LOG.info("Changing account type to LoginType#GOOGLE");
......@@ -159,7 +115,7 @@ public class GoogleOAuthUtil {
}
} catch (UsernameNotFoundException e) {
LOG.info("Username not found, creating new Google account");
user = userService.createUser(userInfo.getAccountEmail(), userInfo.getDisplayName(),null, BasicUser.AccountType.GOOGLE);
user = userService.createUser(tokenPayload.getEmail(), (String) tokenPayload.get("name"), null, BasicUser.AccountType.GOOGLE);
userService.userEmailValidated(UUID.fromString(user.getUuid()));
}
return user;
......
/**
* Copyright 2014 Global Crop Diversity Trust
/*
* Copyright 2018 Global Crop Diversity Trust
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
......@@ -12,12 +12,14 @@
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
**/
*/
package org.genesys2.server.mvc;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashSet;
import java.util.Set;
......@@ -25,15 +27,18 @@ import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.genesys.blocks.security.NotUniqueUserException;
import org.genesys.blocks.security.UserException;
import org.genesys.blocks.security.service.PasswordPolicy.PasswordPolicyException;
import org.genesys2.server.component.security.GoogleOAuthUtil;
import org.genesys2.server.model.impl.User;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.AuthorizationServerTokenServices;
......@@ -41,9 +46,6 @@ import org.springframework.security.web.authentication.AuthenticationFailureHand
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SavedRequestAwareAuthenticationSuccessHandler;
import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler;
import org.springframework.social.google.api.Google;
import org.springframework.social.google.api.impl.GoogleTemplate;
import org.springframework.social.google.api.plus.Person;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
......@@ -51,6 +53,13 @@ import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseBody;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdToken.Payload;
import com.google.api.client.googleapis.auth.oauth2.GoogleIdTokenVerifier;
import com.google.api.client.googleapis.auth.oauth2.GoogleTokenResponse;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
@Controller
public class GoogleSocialController extends BaseController {
......@@ -63,66 +72,81 @@ public class GoogleSocialController extends BaseController {
@Autowired
private AuthorizationServerTokenServices tokenServices;
@Autowired
private ApplicationEventPublisher applicationEventPublisher;
@Value("${google.consumerKey}")
private String googleApiClientId;
@RequestMapping("/google/login")
public void redirectToGoogle(HttpServletResponse response) throws IOException {
response.sendRedirect(googleOAuthUtil.getAuthenticationUrl());
}
/**
* @throws PasswordPolicyException Shouldn't happen
* @throws NotUniqueUserException
* @throws UsernameNotFoundException
*/
@RequestMapping(GoogleOAuthUtil.LOCAL_GOOGLEAUTH_PATH)
public void googleAuth(Model model, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException, UserException {
public void googleAuth(Model model, HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
String accessToken = null;
try {
accessToken = googleOAuthUtil.exchangeForAccessToken(request);
} catch (IOException e) {
LOG.warn(e.getMessage(), e);
}
GoogleTokenResponse googleTokenResponse = googleOAuthUtil.exchangeForAccessToken(request);
if (accessToken == null) {
if (googleTokenResponse == null) {
model.addAttribute("error", true);
authFailureHandler.onAuthenticationFailure(request, response, new BadCredentialsException("Could not authenticate you with Google+"));
authFailureHandler.onAuthenticationFailure(request, response, new BadCredentialsException("Could not authenticate you with Google"));
return;
}
final Google google = new GoogleTemplate(accessToken);
final Person userInfo = google.plusOperations().getGoogleProfile();
googleOAuthUtil.extractUserFromGoogleProfile(userInfo);
final Authentication authentication = googleOAuthUtil.googleAuthentication(userInfo);
// Redirect to URL in session
authSuccessHandler.onAuthenticationSuccess(request, response, authentication);
// Get profile info from ID token
GoogleIdToken idToken = googleTokenResponse.parseIdToken();
try {
User user = googleOAuthUtil.extractUserFromGoogleTokenPayload(idToken.getPayload());
LOG.warn("Google auth for {}", user.getEmail());
final Authentication authentication = googleOAuthUtil.googleAuthentication(idToken.getPayload());
SecurityContextHolder.getContext().setAuthentication(authentication);
applicationEventPublisher.publishEvent(new AuthenticationSuccessEvent(authentication));
// Redirect to URL in session
authSuccessHandler.onAuthenticationSuccess(request, response, authentication);
} catch (UserException e) {
LOG.error(e.getMessage(), e);
}
}
/**
* Google XHR auth.
*
* @param accessToken the access token
* @param tokenId the user's ID token
* @param clientId the client id
* @return the object
*/
@RequestMapping(value = "/google/verify-token", method = RequestMethod.GET)
@ResponseBody
public Object googleAuth(@RequestParam("accessToken") final String accessToken,
@RequestParam("clientId") final String clientId) throws UserException {
final Google google = new GoogleTemplate(accessToken);
final Person userInfo = google.plusOperations().getGoogleProfile();
User user = googleOAuthUtil.extractUserFromGoogleProfile(userInfo);
final Set<String> scope = new HashSet<>(Arrays.asList("trust", "read", "write"));
final OAuth2Request oAuth2Request = new OAuth2Request(null, clientId, user.getAuthorities(), true, scope, null, null, null, null);
final UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
final OAuth2Authentication auth = new OAuth2Authentication(oAuth2Request, authenticationToken);
return tokenServices.createAccessToken(auth);
public Object googleAuth(@RequestParam("tokenId") final String tokenId,
@RequestParam("clientId") final String clientId) throws UserException, IOException, GeneralSecurityException {
GoogleIdTokenVerifier verifier = new GoogleIdTokenVerifier.Builder(new NetHttpTransport(), JacksonFactory.getDefaultInstance())
.setAudience(Collections.singletonList(googleApiClientId)).build();
GoogleIdToken googleIdToken = verifier.verify(tokenId);
if (googleIdToken != null) {
Payload payload = googleIdToken.getPayload();
User user = googleOAuthUtil.extractUserFromGoogleTokenPayload(payload);
final Set<String> scope = new HashSet<>(Arrays.asList("trust", "read", "write"));
final OAuth2Request oAuth2Request = new OAuth2Request(null, clientId, user.getAuthorities(), true, scope, null, null, null, null);
final UsernamePasswordAuthenticationToken authenticationToken = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities());
final OAuth2Authentication authentication = new OAuth2Authentication(oAuth2Request, authenticationToken);
applicationEventPublisher.publishEvent(new AuthenticationSuccessEvent(authentication));
return tokenServices.createAccessToken(authentication);
} else {
throw new BadCredentialsException("Could not authenticate you with Google");
}
}
}
......@@ -366,33 +366,28 @@ public class SubsetServiceImpl implements SubsetService {
accessionRefRepository.save(sArs);
loadedSubset.setAccessionCount((int) accessionRefRepository.countBySubset(loadedSubset));
LOG.warn("Done saving {} accession refs, have {} in subset", accessionRefs.size(), loadedSubset.getAccessionCount());
subsetRepository.save(loadedSubset);
threadPoolTaskExecutor.execute(() -> {
try {
Thread.sleep(2000);
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
// explicitly setting the transaction name is something that can only be done
// programmatically
def.setName("SomeTxName");
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
TransactionStatus status = transactionManager.getTransaction(def);
try {
// execute your business logic here
rematchSubsetAccessions(sArs);
} catch (Throwable ex) {
LOG.error("Rolling back rematch. Exception: {}", ex.getMessage(), ex);
transactionManager.rollback(status);
throw ex;
}
transactionManager.commit(status);
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
// explicitly setting the transaction name is something that can only be done
// programmatically
def.setName("SomeTxName");
def.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRED);
} catch (InterruptedException e) {
LOG.warn("Interrupted rematcher");
TransactionStatus status = transactionManager.getTransaction(def);
try {
// execute your business logic here
rematchSubsetAccessions(sArs);
} catch (Throwable ex) {
LOG.error("Rolling back rematch. Exception: {}", ex.getMessage(), ex);
transactionManager.rollback(status);
throw ex;
}
transactionManager.commit(status);
});
return lazyLoad(subsetRepository.save(loadedSubset));
return lazyLoad(loadedSubset);
}
/**
......
......@@ -41,6 +41,7 @@ login.register-now=Create an account
logout=Logout
login.forgot-password=Forgot password
login.with-google-plus=Login with Google+
login.with-google-sign-in=Login with Google
# Registration
registration.page.title=Create a user account
......
......@@ -167,7 +167,7 @@
<spring:message code="login.login-button" />
</button>
<span class="or">-</span>
<a href="<c:url value="/google/login" />" class="btn btn-default google-signin"> <spring:message code="login.with-google-plus" />
<a href="<c:url value="/google/login" />" class="btn btn-default google-signin"> <spring:message code="login.with-google-sign-in" />
</a> <a href="<c:url value="/registration" />" class="btn btn-default"> <spring:message code="login.register-now" />
</a>
<!-- CSRF protection -->
......
......@@ -43,7 +43,7 @@
<div class="form-group transparent">
<div class="col-lg-offset-2 col-lg-10 col-md-offset-3 col-md-9 col-sm-offset-3 col-sm-9 col-xs-12">
<input type="submit" value="<spring:message code="login.login-button" />" class="btn btn-primary" />
<a href="<c:url value="/google/login" />" class="btn btn-default google-signin"><spring:message code="login.with-google-plus"/></a>
<a href="<c:url value="/google/login" />" class="btn btn-default google-signin"><spring:message code="login.with-google-sign-in"/></a>
<a href="<c:url value="/registration" />" id="registration" class="btn btn-default"><spring:message code="login.register-now"/></a>
<a href="<c:url value="/profile/forgot-password" />" id="forgot-password" class="btn btn-default"><spring:message code="login.forgot-password"/></a>
</div>
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment