From 4b340fce54f704b2d8cc0b9f01475264e174282e Mon Sep 17 00:00:00 2001 From: Sourabh Agrawal Date: Wed, 8 Dec 2021 19:14:50 +0530 Subject: [PATCH 1/2] SAML Integration for Login --- build.gradle | 1 + front-end/src/api/login.js | 8 +++++++ front-end/src/store/modules/user.js | 23 +++++++++++++++++++ front-end/src/views/login/index.vue | 16 +++++++++++++ .../manager/controller/LoginController.java | 21 +++++++++++++++++ .../manager/dao/UsersRepositoryImpl.java | 5 ++++ .../manager/entity/UsersRepository.java | 8 +++++++ .../manager/interceptor/WebAppConfigurer.java | 1 + .../pulsar/manager/mapper/UsersMapper.java | 6 +++++ src/main/resources/application.properties | 5 ++++ 10 files changed, 94 insertions(+) diff --git a/build.gradle b/build.gradle index 26315a85..0fc1fc52 100644 --- a/build.gradle +++ b/build.gradle @@ -142,6 +142,7 @@ dependencies { compile group: 'org.glassfish.jersey.media', name: 'jersey-media-json-jackson', version: jerseyVersion compile group: 'org.springframework.boot', name: 'spring-boot-starter-security' compile group: 'org.springframework.security', name: 'spring-security-config' + compile group: 'org.opensaml', name: 'opensaml', version: '2.6.4' compileOnly group: 'org.projectlombok', name: 'lombok', version: lombokVersion compileOnly group: 'org.springframework.boot', name: 'spring-boot-devtools', version: springBootVersion testCompile group: 'org.springframework.boot', name: 'spring-boot-starter-test', version: springBootVersion diff --git a/front-end/src/api/login.js b/front-end/src/api/login.js index 0c3ca2e7..dec5bbb1 100644 --- a/front-end/src/api/login.js +++ b/front-end/src/api/login.js @@ -40,3 +40,11 @@ export function getUserInfo(token) { params: { token } }) } + +export function loginByToken(token) { + return request({ + headers: { 'Content-Type': 'application/json' }, + url: '/pulsar-manager/login?token=' + token, + method: 'get' + }) +} diff --git a/front-end/src/store/modules/user.js b/front-end/src/store/modules/user.js index b2acd3c1..d795378c 100644 --- a/front-end/src/store/modules/user.js +++ b/front-end/src/store/modules/user.js @@ -19,6 +19,7 @@ import { removeCsrfToken } from '@/utils/csrfToken' import { Message } from 'element-ui' import { setTenant, removeTenant } from '../../utils/tenant' import { getUserInfo } from '@/api/users' +import { loginByToken } from '../../api/login' const user = { state: { @@ -149,6 +150,28 @@ const user = { // resolve() // }) }) + }, + + LoginByToken({ commit }, token) { + return new Promise((resolve, reject) => { + loginByToken(token).then(response => { + if (response.data.hasOwnProperty('error') && response.data.error.length >= 0) { + Message({ + message: 'Invalid Access Token Provided', + type: 'error', + duration: 5 * 1000 + }) + reject('login error') + } + commit('SET_TOKEN', response.headers.token) + setToken(response.headers.token) + setName(response.headers.username) + setTenant(response.headers.tenant) + resolve() + }).catch(error => { + reject(error) + }) + }) } } } diff --git a/front-end/src/views/login/index.vue b/front-end/src/views/login/index.vue index f4b79c3b..6f278d27 100644 --- a/front-end/src/views/login/index.vue +++ b/front-end/src/views/login/index.vue @@ -128,6 +128,11 @@ export default { mounted() { window.addEventListener('message', this.handleMessage) }, + beforeMount() { + if (location.search.includes('token')) { + this.handleLoginByToken() + } + }, methods: { showPwd() { if (this.passwordType === 'password') { @@ -158,6 +163,17 @@ export default { } }) }, + handleLoginByToken() { + const urlParams = new URLSearchParams(window.location.search) + const token = (urlParams.get('token')) + this.$store.dispatch('LoginByToken', token).then(() => { + this.loading = false + window.location = '/' + this.$router.push({ path: '/' }) + }).catch(() => { + this.loading = false + }) + }, afterQRScan() { // const hash = window.location.hash.slice(1) // const hashObj = getQueryObject(hash) diff --git a/src/main/java/org/apache/pulsar/manager/controller/LoginController.java b/src/main/java/org/apache/pulsar/manager/controller/LoginController.java index 4973a324..ae96b9bc 100644 --- a/src/main/java/org/apache/pulsar/manager/controller/LoginController.java +++ b/src/main/java/org/apache/pulsar/manager/controller/LoginController.java @@ -170,4 +170,25 @@ public ResponseEntity> logout() { jwtService.removeToken(request.getSession().getId()); return ResponseEntity.ok(result); } + + @RequestMapping(value = "/login", method = RequestMethod.GET) + public ResponseEntity> loginByAccessToken( + HttpServletRequest request) { + String token = request.getParameter("token"); + Optional userInfoEntity = usersRepository.findByAccessToken(token); + Map result = Maps.newHashMap(); + if (!userInfoEntity.isPresent()) + { + result.put("error", "No user found with the access token"); + return ResponseEntity.ok(result); + } + HttpHeaders headers = new HttpHeaders(); + if (userInfoEntity.isPresent()){ + UserInfoEntity user = userInfoEntity.get(); + headers.add("token",token); + headers.add("username", user.getName()); + headers.add("tenant", user.getName()); + } + return new ResponseEntity<>(result, headers, HttpStatus.OK); + } } diff --git a/src/main/java/org/apache/pulsar/manager/dao/UsersRepositoryImpl.java b/src/main/java/org/apache/pulsar/manager/dao/UsersRepositoryImpl.java index 5a9b9ded..b303b0e3 100644 --- a/src/main/java/org/apache/pulsar/manager/dao/UsersRepositoryImpl.java +++ b/src/main/java/org/apache/pulsar/manager/dao/UsersRepositoryImpl.java @@ -75,4 +75,9 @@ public void update(UserInfoEntity userInfoEntity) { public void delete(String name) { this.usersMapper.delete(name); } + + @Override + public Optional findByEmail(String email) { + return Optional.ofNullable(this.usersMapper.findByEmail(email)); + } } diff --git a/src/main/java/org/apache/pulsar/manager/entity/UsersRepository.java b/src/main/java/org/apache/pulsar/manager/entity/UsersRepository.java index 8f975b24..ca1da2b7 100644 --- a/src/main/java/org/apache/pulsar/manager/entity/UsersRepository.java +++ b/src/main/java/org/apache/pulsar/manager/entity/UsersRepository.java @@ -76,4 +76,12 @@ public interface UsersRepository { * @param name username */ void delete(String name); + + /** + * Get a user information by email. + * @param email The email + * @return UserInfoEntity + */ + Optional findByEmail(String email); + } diff --git a/src/main/java/org/apache/pulsar/manager/interceptor/WebAppConfigurer.java b/src/main/java/org/apache/pulsar/manager/interceptor/WebAppConfigurer.java index ddfd79fe..a12724eb 100644 --- a/src/main/java/org/apache/pulsar/manager/interceptor/WebAppConfigurer.java +++ b/src/main/java/org/apache/pulsar/manager/interceptor/WebAppConfigurer.java @@ -51,6 +51,7 @@ public void addInterceptors(InterceptorRegistry registry) { .excludePathPatterns("/doc.html") // BKVM .excludePathPatterns("/bkvm") + .excludePathPatterns("/pulsar-manager/saml/**") ; } diff --git a/src/main/java/org/apache/pulsar/manager/mapper/UsersMapper.java b/src/main/java/org/apache/pulsar/manager/mapper/UsersMapper.java index e1e06f69..bd84d21a 100644 --- a/src/main/java/org/apache/pulsar/manager/mapper/UsersMapper.java +++ b/src/main/java/org/apache/pulsar/manager/mapper/UsersMapper.java @@ -76,4 +76,10 @@ public interface UsersMapper { @Delete("DELETE FROM users WHERE name=#{name}") void delete(String name); + + @Select("SELECT access_token AS accessToken, user_id AS userId, name, description, email," + + "phone_number AS phoneNumber, location, company, expire, password " + + "FROM users " + + "WHERE email = #{email}") + UserInfoEntity findByEmail(String email); } diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index ba598eb9..28560dfc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -156,3 +156,8 @@ pulsar.peek.message=false # swagger configration swagger.enabled=false +sso.redirect.host=localhost +sso.redirect.port=9527 +sso.redirect.scheme=http +sso.certificate= +sso.whitelisted.domain= From 8587b1d67fc9cf60aa4c7782259786419716f5fb Mon Sep 17 00:00:00 2001 From: Sourabh Agrawal Date: Thu, 9 Dec 2021 10:08:19 +0530 Subject: [PATCH 2/2] Pushed missing class --- .../manager/controller/SSOController.java | 131 ++++++++++++ .../pulsar/manager/utils/SAMLParser.java | 192 ++++++++++++++++++ 2 files changed, 323 insertions(+) create mode 100644 src/main/java/org/apache/pulsar/manager/controller/SSOController.java create mode 100644 src/main/java/org/apache/pulsar/manager/utils/SAMLParser.java diff --git a/src/main/java/org/apache/pulsar/manager/controller/SSOController.java b/src/main/java/org/apache/pulsar/manager/controller/SSOController.java new file mode 100644 index 00000000..b9161e64 --- /dev/null +++ b/src/main/java/org/apache/pulsar/manager/controller/SSOController.java @@ -0,0 +1,131 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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.apache.pulsar.manager.controller; + +import io.swagger.annotations.Api; +import io.swagger.annotations.ApiOperation; +import io.swagger.annotations.ApiResponse; +import io.swagger.annotations.ApiResponses; +import java.net.URI; +import java.util.Map; +import java.util.Optional; +import javax.annotation.Resource; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.codec.digest.DigestUtils; +import org.apache.commons.lang3.StringUtils; +import org.apache.http.client.utils.URIBuilder; +import org.apache.http.util.Asserts; +import org.apache.pulsar.manager.entity.RoleBindingEntity; +import org.apache.pulsar.manager.entity.RoleBindingRepository; +import org.apache.pulsar.manager.entity.RolesRepository; +import org.apache.pulsar.manager.entity.UserInfoEntity; +import org.apache.pulsar.manager.entity.UsersRepository; +import org.apache.pulsar.manager.service.JwtService; +import org.apache.pulsar.manager.utils.SAMLParser; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestMethod; +import org.springframework.web.bind.annotation.ResponseBody; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@RestController +@RequestMapping(value = "/pulsar-manager/saml") +@Api(description = "Calling the request below this class does not require authentication because " + + "the user has not logged in yet.") +@Validated +public class SSOController { + + + @Autowired + private UsersRepository usersRepository; + + @Autowired + private JwtService jwtService; + + @Resource + private RolesRepository rolesRepository; + + @Resource + private RoleBindingRepository roleBindingRepository; + + @Value("${sso.redirect.host}") + private String redirectHost; + + @Value("${sso.redirect.port}") + private int redirectPort; + + @Value("${sso.redirect.scheme}") + private String redirectUrlScheme; + + @Value("${sso.certificate}") + private String certificate; + + @Value("${sso.whitelisted.domain}") + private String whiteListedDomain; + + @ApiOperation(value = "Verify SAML Response") + @ApiResponses({ + @ApiResponse(code = 200, message = "ok"), + @ApiResponse(code = 404, message = "Not found"), + @ApiResponse(code = 500, message = "Internal server error") + }) + @RequestMapping(value = "/sso", method = RequestMethod.POST) + @ResponseBody + public void loginByIDP(HttpServletRequest request, HttpServletResponse response) throws Exception { + String samlResponse = request.getParameter("SAMLResponse"); + SAMLParser samlParser = new SAMLParser(certificate); + Map userDetails = samlParser.getUserDetailsFromSAMLResponse(samlResponse); + Asserts.check(StringUtils.contains(userDetails.get("email"),whiteListedDomain),"Login with domain not supported"); + Optional userInfoEntity = usersRepository.findByEmail(userDetails.get("email")); + if (!userInfoEntity.isPresent()) { + UserInfoEntity userEntity = new UserInfoEntity(); + userEntity.setName(getUsername(userDetails.get("email"))); + userEntity.setEmail(userDetails.get("email")); + userEntity.setPassword(DigestUtils.sha256Hex("pulsar")); + usersRepository.save(userEntity); + userInfoEntity = usersRepository.findByEmail(userDetails.get("email")); + long superUserRoleID = rolesRepository.findByRoleFlag(0).get().getRoleId(); + RoleBindingEntity roleBindingEntity = new RoleBindingEntity(); + roleBindingEntity.setRoleId(superUserRoleID); + roleBindingEntity.setUserId(userEntity.getUserId()); + roleBindingEntity.setName("Role created after sso signin"); + roleBindingRepository.save(roleBindingEntity); + + } + UserInfoEntity user = userInfoEntity.get(); + String userAccount = user.getName(); + String password = user.getPassword(); + String token = jwtService.toToken(userAccount + password + System.currentTimeMillis()); + user.setAccessToken(token); + usersRepository.update(user); + jwtService.setToken(request.getSession().getId(), token); + URI uri = new URIBuilder().setScheme(redirectUrlScheme).setHost(redirectHost).setPort(redirectPort).setPath("/login") + .addParameter("token",token) + .build(); + response.setStatus(302); + response.sendRedirect(uri.toString()); + } + + private String getUsername(String email) { + String userName = StringUtils.split(email,"@")[0]; + return StringUtils.replace(userName,".",""); + } + +} + diff --git a/src/main/java/org/apache/pulsar/manager/utils/SAMLParser.java b/src/main/java/org/apache/pulsar/manager/utils/SAMLParser.java new file mode 100644 index 00000000..9395b802 --- /dev/null +++ b/src/main/java/org/apache/pulsar/manager/utils/SAMLParser.java @@ -0,0 +1,192 @@ +/** + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * 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.apache.pulsar.manager.utils; +import com.google.common.collect.Maps; +import java.io.ByteArrayInputStream; +import java.io.StringReader; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.PublicKey; +import java.security.cert.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.X509EncodedKeySpec; +import java.util.List; + +import java.util.Map; +import java.util.concurrent.TimeoutException; +import lombok.Data; +import org.apache.commons.codec.binary.Base64; + +import org.joda.time.DateTime; +import org.joda.time.DateTimeZone; +import org.joda.time.Duration; + +import org.opensaml.Configuration; +import org.opensaml.saml2.core.AuthnStatement; +import org.opensaml.saml2.core.NameID; +import org.opensaml.saml2.core.Response; +import org.opensaml.xml.XMLObject; +import org.opensaml.xml.io.Unmarshaller; +import org.opensaml.xml.io.UnmarshallerFactory; +import org.opensaml.xml.parse.BasicParserPool; +import org.opensaml.xml.security.x509.BasicX509Credential; +import org.opensaml.xml.signature.Signature; +import org.opensaml.xml.signature.SignatureValidator; +import org.opensaml.xml.validation.ValidationException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.w3c.dom.Document; +import org.w3c.dom.Element; + +@Data +public class SAMLParser { + + private static final Logger logger = LoggerFactory + .getLogger(SAMLParser.class); + + private String certificateString; + + public SAMLParser(String certificate) { + super(); + this.certificateString = certificate; + } + + private org.opensaml.saml2.core.Response validateRequestTimeoutAndSignature(String saml) throws Exception { + + /* + * bootstrap the opensaml stuff + */ + org.opensaml.DefaultBootstrap.bootstrap(); + + org.opensaml.saml2.core.Response samlResponse = (Response) unmarshall(saml); + List authnStatements = samlResponse.getAssertions().get(0).getAuthnStatements(); + + for (AuthnStatement authnStatement : authnStatements) { + + DateTime samlCreationTime = authnStatement.getAuthnInstant(); + DateTime currentTime = new DateTime(DateTimeZone.UTC); + Duration duration = new Duration(samlCreationTime, currentTime); + + Long requestDuration = duration.getStandardSeconds(); + + /* + * verify request time out in seconds + */ + if (requestDuration > 60) { + throw new TimeoutException("Saml Request is older than 60 seconds"); + } + } + + Signature signature = samlResponse.getSignature(); + validateSignature(this.getCertificateString(), signature); + + return samlResponse; + } + + /** + * Unmarshall XML to POJOs (These POJOs will be OpenSAML objects.) + * + * @return The root OpenSAML object. + */ + private XMLObject unmarshall(String samlResponse) throws Exception { + + BasicParserPool parser = new BasicParserPool(); + parser.setNamespaceAware(true); + + /* + * string is passed as base64, decode it and then unmarshal the response + */ + byte[] base64Decoded = Base64.decodeBase64(samlResponse); + + /* + * uncomment following to see the SAML response received + */ + + StringReader reader = new StringReader(new String(base64Decoded)); + + Document doc = parser.parse(reader); + Element samlElement = doc.getDocumentElement(); + + UnmarshallerFactory unmarshallerFactory = Configuration + .getUnmarshallerFactory(); + Unmarshaller unmarshaller = unmarshallerFactory + .getUnmarshaller(samlElement); + if (unmarshaller == null) { + + logger.error("failed to unmarshal the saml response recieved"); + throw new Exception("Failed to unmarshal"); + } + + return unmarshaller.unmarshall(samlElement); + } + + private void validateSignature(String idpCertificateString, + Signature signature) throws CertificateException, + NoSuchAlgorithmException, InvalidKeySpecException, + ValidationException { + + byte[] decoded = org.opensaml.xml.util.Base64 + .decode(idpCertificateString); + + /* + * generate certificate from certificate string + */ + X509Certificate certificate = (X509Certificate) CertificateFactory + .getInstance("X.509").generateCertificate( + new ByteArrayInputStream(decoded)); + + /* + * pull out the public key part of the certificate into a KeySpec + */ + X509EncodedKeySpec publicKeySpec = new X509EncodedKeySpec(certificate + .getPublicKey().getEncoded()); + + /* + * generate public key from the public key part + */ + KeyFactory keyFactory = KeyFactory.getInstance("RSA"); + PublicKey publicKey = keyFactory.generatePublic(publicKeySpec); + BasicX509Credential publicCredential = new BasicX509Credential(); + publicCredential.setPublicKey(publicKey); + + // create SignatureValidator + org.opensaml.xml.signature.SignatureValidator signatureValidator = new SignatureValidator( + publicCredential); + + /* + * try to validate will throw ValidationException if signature is + * invalid + */ + signatureValidator.validate(signature); + } + + private Map getUserDetailsFromSAMLResponse( + Response samlResponse) { + Map userDetails = Maps.newHashMap(); + NameID nameID = samlResponse.getAssertions() + .get(0).getSubject().getNameID(); + userDetails.put("email",nameID.getValue()); + return userDetails; + } + + public Map getUserDetailsFromSAMLResponse(String samlResponse) throws Exception { + Response saml = validateRequestTimeoutAndSignature(samlResponse); + return getUserDetailsFromSAMLResponse(saml); + } + + +} +