From 8f7b2af21beda60d6123f555a0c21d2eadfc777a Mon Sep 17 00:00:00 2001 From: Vitaly Takmazov Date: Fri, 20 Dec 2019 16:28:41 +0300 Subject: Sign In With Apple --- build.gradle | 6 ++ .../scribejava/apis/AppleClientAuthentication.java | 33 +++++++ .../apis/AppleClientSecretGenerator.java | 85 ++++++++++++++++++ .../com/github/scribejava/apis/AppleSignInApi.java | 45 ++++++++++ .../configuration/SignInWithAppleConfig.java | 44 +++++++++ .../juick/server/www/controllers/SocialLogin.java | 100 ++++++++++++++++++++- src/main/resources/templates/views/login.html | 3 + src/test/java/com/juick/JWTTest.java | 94 +++++++++++++++++++ src/test/resources/testkey.p8 | 5 ++ 9 files changed, 411 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/github/scribejava/apis/AppleClientAuthentication.java create mode 100644 src/main/java/com/github/scribejava/apis/AppleClientSecretGenerator.java create mode 100644 src/main/java/com/github/scribejava/apis/AppleSignInApi.java create mode 100644 src/main/java/com/juick/server/configuration/SignInWithAppleConfig.java create mode 100644 src/test/java/com/juick/JWTTest.java create mode 100644 src/test/resources/testkey.p8 diff --git a/build.gradle b/build.gradle index 9d350fc2..2a2cdf15 100644 --- a/build.gradle +++ b/build.gradle @@ -172,6 +172,12 @@ dependencies { compile 'com.google.api-client:google-api-client:1.30.6' compile "com.kotcrab.remark:remark:1.2.0" + compile 'io.jsonwebtoken:jjwt-api:0.10.7' + runtime 'io.jsonwebtoken:jjwt-jackson:0.10.7' + runtime 'io.jsonwebtoken:jjwt-impl:0.10.7' + compile 'org.bouncycastle:bcpkix-jdk15on:1.64' + compile 'com.nimbusds:nimbus-jose-jwt:8.3' + testCompile("org.springframework.boot:spring-boot-starter-test") testCompile('net.sourceforge.htmlunit:htmlunit:2.36.0') testCompile('org.springframework.security:spring-security-test') diff --git a/src/main/java/com/github/scribejava/apis/AppleClientAuthentication.java b/src/main/java/com/github/scribejava/apis/AppleClientAuthentication.java new file mode 100644 index 00000000..6bb919a3 --- /dev/null +++ b/src/main/java/com/github/scribejava/apis/AppleClientAuthentication.java @@ -0,0 +1,33 @@ +/* + * Copyright (C) 2008-2019, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.github.scribejava.apis; + +import com.github.scribejava.core.model.OAuthRequest; +import com.github.scribejava.core.oauth2.clientauthentication.ClientAuthentication; + +public class AppleClientAuthentication implements ClientAuthentication { + private final AppleClientSecretGenerator generator; + public AppleClientAuthentication(AppleClientSecretGenerator generator) { + this.generator = generator; + } + @Override + public void addClientAuthentication(OAuthRequest request, String apiKey, String apiSecret) { + request.addBodyParameter("client_id", generator.getApplicationId()); + request.addBodyParameter("client_secret", generator.getClientSecret()); + } +} diff --git a/src/main/java/com/github/scribejava/apis/AppleClientSecretGenerator.java b/src/main/java/com/github/scribejava/apis/AppleClientSecretGenerator.java new file mode 100644 index 00000000..3af6bc7a --- /dev/null +++ b/src/main/java/com/github/scribejava/apis/AppleClientSecretGenerator.java @@ -0,0 +1,85 @@ +/* + * Copyright (C) 2008-2019, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.github.scribejava.apis; + +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import org.apache.commons.io.FileUtils; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.security.KeyFactory; +import java.security.NoSuchAlgorithmException; +import java.security.spec.EncodedKeySpec; +import java.security.spec.InvalidKeySpecException; +import java.security.spec.PKCS8EncodedKeySpec; +import java.time.*; +import java.util.Base64; +import java.util.Date; + +public class AppleClientSecretGenerator { + private final String subject; + private final String teamId; + private final String keyId; + + private final Key signingKey; + + public AppleClientSecretGenerator(final String subject, final String teamId, final String keyId, final String keyPath) + throws IOException, NoSuchAlgorithmException, InvalidKeySpecException { + this.subject = subject; + this.keyId = keyId; + this.teamId = teamId; + + String pemData = FileUtils.readFileToString(new File(keyPath), StandardCharsets.UTF_8); + String p8encodedData = pemData + .replace( + "-----BEGIN PRIVATE KEY-----\n", "") + .replace("\n", "") + .replace("-----END PRIVATE KEY-----", ""); + KeyFactory kf = KeyFactory.getInstance("EC"); + EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(p8encodedData)); + signingKey = kf.generatePrivate(keySpec); + } + + public String getClientSecret() { + Instant now = Instant.now(); + return Jwts.builder() + .setHeaderParam("kid", keyId) + .setIssuer(teamId) + .setAudience("https://appleid.apple.com") + .setIssuedAt(Date.from(now)) + .setSubject(subject) + .setExpiration(Date.from(ZonedDateTime.ofInstant(now, ZoneId.of("UTC")).plusMonths(1).toInstant())) + .signWith(signingKey, SignatureAlgorithm.ES256) + .compact(); + } + + public String getTeamId() { + return teamId; + } + + public String getKeyId() { + return keyId; + } + + public String getApplicationId() { + return subject; + } +} diff --git a/src/main/java/com/github/scribejava/apis/AppleSignInApi.java b/src/main/java/com/github/scribejava/apis/AppleSignInApi.java new file mode 100644 index 00000000..be14ef16 --- /dev/null +++ b/src/main/java/com/github/scribejava/apis/AppleSignInApi.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2008-2019, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.github.scribejava.apis; + +import com.github.scribejava.core.builder.api.DefaultApi20; +import com.github.scribejava.core.oauth2.clientauthentication.ClientAuthentication; + +public class AppleSignInApi extends DefaultApi20 { + + private final AppleClientSecretGenerator clientSecretGenerator; + + public AppleSignInApi(AppleClientSecretGenerator clientSecretGenerator) { + this.clientSecretGenerator = clientSecretGenerator; + } + + @Override + public String getAccessTokenEndpoint() { + return "https://appleid.apple.com/auth/token"; + } + + @Override + protected String getAuthorizationBaseUrl() { + return "https://appleid.apple.com/auth/authorize?response_mode=form_post"; + } + + @Override + public ClientAuthentication getClientAuthentication() { + return new AppleClientAuthentication(clientSecretGenerator); + } +} diff --git a/src/main/java/com/juick/server/configuration/SignInWithAppleConfig.java b/src/main/java/com/juick/server/configuration/SignInWithAppleConfig.java new file mode 100644 index 00000000..f736e673 --- /dev/null +++ b/src/main/java/com/juick/server/configuration/SignInWithAppleConfig.java @@ -0,0 +1,44 @@ +/* + * Copyright (C) 2008-2019, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.juick.server.configuration; + +import com.github.scribejava.apis.AppleClientSecretGenerator; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.io.IOException; +import java.security.NoSuchAlgorithmException; +import java.security.spec.InvalidKeySpecException; + +@Configuration +public class SignInWithAppleConfig { + @Value("${apple_app_id:com.example.app}") + private String appId; + @Value("${apple_team_id:teamid}") + private String teamId; + @Value("${apple_key_id:keyid}") + private String keyId; + @Value("${apple_key_path:AuthKey.p8}") + private String keyPath; + + @Bean + public AppleClientSecretGenerator clientSecretGenerator() throws IOException, InvalidKeySpecException, NoSuchAlgorithmException { + return new AppleClientSecretGenerator(appId, teamId, keyId, keyPath); + } +} diff --git a/src/main/java/com/juick/server/www/controllers/SocialLogin.java b/src/main/java/com/juick/server/www/controllers/SocialLogin.java index 7d019940..92f83c4e 100644 --- a/src/main/java/com/juick/server/www/controllers/SocialLogin.java +++ b/src/main/java/com/juick/server/www/controllers/SocialLogin.java @@ -17,6 +17,8 @@ package com.juick.server.www.controllers; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.scribejava.apis.AppleClientSecretGenerator; +import com.github.scribejava.apis.AppleSignInApi; import com.github.scribejava.apis.FacebookApi; import com.github.scribejava.apis.TwitterApi; import com.github.scribejava.apis.VkontakteApi; @@ -33,9 +35,22 @@ import com.juick.service.EmailService; import com.juick.service.TelegramService; import com.juick.service.UserService; import com.juick.service.security.annotation.Visitor; +import com.nimbusds.jose.JOSEException; +import com.nimbusds.jose.JWSAlgorithm; +import com.nimbusds.jose.jwk.source.JWKSource; +import com.nimbusds.jose.jwk.source.RemoteJWKSet; +import com.nimbusds.jose.proc.BadJOSEException; +import com.nimbusds.jose.proc.JWSKeySelector; +import com.nimbusds.jose.proc.JWSVerificationKeySelector; +import com.nimbusds.jose.proc.SecurityContext; +import com.nimbusds.jwt.proc.ConfigurableJWTProcessor; +import com.nimbusds.jwt.proc.DefaultJWTClaimsVerifier; +import com.nimbusds.jwt.proc.DefaultJWTProcessor; +import net.minidev.json.JSONObject; import org.apache.commons.codec.digest.DigestUtils; import org.apache.commons.codec.digest.HmacAlgorithms; import org.apache.commons.codec.digest.HmacUtils; +import org.apache.commons.lang3.RandomStringUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.math.NumberUtils; import org.slf4j.Logger; @@ -44,6 +59,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.CookieValue; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.util.UriComponentsBuilder; @@ -53,6 +69,8 @@ import javax.servlet.http.Cookie; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URL; +import java.text.ParseException; import java.util.List; import java.util.Map; import java.util.UUID; @@ -79,7 +97,7 @@ public class SocialLogin { @Inject private ObjectMapper jsonMapper; private ServiceBuilder twitterBuilder; - private OAuth20Service facebookAuthService, vkAuthService; + private OAuth20Service facebookAuthService, vkAuthService, appleSignInService; @Value("${twitter_consumer_key:appid}") private String twitterConsumerKey; @@ -91,7 +109,8 @@ public class SocialLogin { private String VK_SECRET; @Value("${telegram_token:secret}") private String telegramToken; - + @Value("${apple_app_id:appid}") + private String appleApplicationId; @Inject private CrosspostService crosspostService; @Inject @@ -100,14 +119,16 @@ public class SocialLogin { private EmailService emailService; @Inject private TelegramService telegramService; + @Inject + private AppleClientSecretGenerator clientSecretGenerator; @PostConstruct public void init() { ServiceBuilder facebookBuilder = new ServiceBuilder(FACEBOOK_APPID); twitterBuilder = new ServiceBuilder(twitterConsumerKey); ServiceBuilder vkBuilder = new ServiceBuilder(VK_APPID); - UriComponentsBuilder facebookRedirectBuilder = UriComponentsBuilder.fromUriString(baseUri); - String facebookRedirectUri = facebookRedirectBuilder.replacePath("/_fblogin").build().toUriString(); + UriComponentsBuilder redirectBuilder = UriComponentsBuilder.fromUriString(baseUri); + String facebookRedirectUri = redirectBuilder.replacePath("/_fblogin").build().toUriString(); facebookAuthService = facebookBuilder .apiSecret(FACEBOOK_SECRET) .callback(facebookRedirectUri) @@ -118,6 +139,12 @@ public class SocialLogin { .defaultScope("friends,wall,offline") .callback(VK_REDIRECT) .build(VkontakteApi.instance()); + ServiceBuilder appleSignInBuilder = new ServiceBuilder(appleApplicationId); + String appleSignInRedirectUri = redirectBuilder.replacePath("/_apple").build().toUriString(); + appleSignInService = appleSignInBuilder + .callback(appleSignInRedirectUri) + .defaultScope("email") + .build(new AppleSignInApi(clientSecretGenerator)); } @GetMapping("/_fblogin") @@ -315,4 +342,69 @@ public class SocialLogin { } throw new HttpBadRequestException(); } + + @GetMapping("/_apple") + public String doAppleLogin(HttpServletRequest request, + @RequestParam(required = false) String code, + HttpServletResponse response) { + if (StringUtils.isBlank(code)) { + String state = UUID.randomUUID().toString(); + Cookie c = new Cookie("astate", state); + response.addCookie(c); + return "redirect:" + appleSignInService.getAuthorizationUrl(state); + } + throw new HttpBadRequestException(); + } + @PostMapping("/_apple") + public String doVerifyAppleResponse(HttpServletRequest request, HttpServletResponse response, @RequestParam Map body) throws InterruptedException, ExecutionException, IOException, ParseException, JOSEException, BadJOSEException { + OAuth2AccessToken token = appleSignInService.getAccessToken(body.get("code")); + var jsonNode = jsonMapper.readTree(token.getRawResponse()); + var idToken = jsonNode.get("id_token").textValue(); + +// Create a JWT processor for the access tokens + ConfigurableJWTProcessor jwtProcessor = + new DefaultJWTProcessor<>(); + +// The public RSA keys to validate the signatures will be sourced from the +// OAuth 2.0 server's JWK set, published at a well-known URL. The RemoteJWKSet +// object caches the retrieved keys to speed up subsequent look-ups and can +// also handle key-rollover + JWKSource keySource = + new RemoteJWKSet<>(new URL("https://appleid.apple.com/auth/keys")); + +// The expected JWS algorithm of the access tokens (agreed out-of-band) + JWSAlgorithm expectedJWSAlg = JWSAlgorithm.RS256; + +// Configure the JWT processor with a key selector to feed matching public +// RSA keys sourced from the JWK set URL + JWSKeySelector keySelector = + new JWSVerificationKeySelector<>(expectedJWSAlg, keySource); + + jwtProcessor.setJWSKeySelector(keySelector); + +// Set the required JWT claims for access tokens issued by the Connect2id +// server, may differ with other servers + jwtProcessor.setJWTClaimsSetVerifier(new DefaultJWTClaimsVerifier<>()); + +// Process the token + JSONObject claimsSet = jwtProcessor.process(idToken, null).toJSONObject(); + + var email = claimsSet.getAsString("email"); + var verified = claimsSet.getAsString("email_verified").equals("true"); + + if (verified) { + com.juick.User user = userService.getUserByEmail(email); + if (!user.isAnonymous()) { + Cookie c = new Cookie("hash", userService.getHashByUID(user.getUid())); + c.setMaxAge(50 * 24 * 60 * 60); + response.addCookie(c); + return "redirect:/" + Utils.getPreviousPageByRequest(request).orElse(StringUtils.EMPTY); + } else { + String verificationCode = RandomStringUtils.randomAlphanumeric(8).toUpperCase(); + emailService.addVerificationCode(null, email, verificationCode); + return "redirect:/signup?type=email&hash=" + verificationCode; + } + } + throw new HttpBadRequestException(); + } } diff --git a/src/main/resources/templates/views/login.html b/src/main/resources/templates/views/login.html index 11900c8d..0fdfb7e2 100644 --- a/src/main/resources/templates/views/login.html +++ b/src/main/resources/templates/views/login.html @@ -128,6 +128,9 @@ data-telegram-login="Juick_bot" data-size="medium" data-radius="0" data-auth-url="https://juick.com/_tglogin" data-request-access="write"> +
+ +
diff --git a/src/test/java/com/juick/JWTTest.java b/src/test/java/com/juick/JWTTest.java new file mode 100644 index 00000000..1a6eeada --- /dev/null +++ b/src/test/java/com/juick/JWTTest.java @@ -0,0 +1,94 @@ +/* + * Copyright (C) 2008-2019, Juick + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of the + * License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package com.juick; + +import com.github.scribejava.apis.AppleClientSecretGenerator; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jws; +import io.jsonwebtoken.Jwts; +import org.apache.commons.io.FileUtils; +import org.bouncycastle.asn1.pkcs.PrivateKeyInfo; +import org.bouncycastle.jce.ECNamedCurveTable; +import org.bouncycastle.jce.interfaces.ECPrivateKey; +import org.bouncycastle.jce.interfaces.ECPublicKey; +import org.bouncycastle.jce.spec.ECParameterSpec; +import org.bouncycastle.jce.spec.ECPublicKeySpec; +import org.bouncycastle.math.ec.ECPoint; +import org.bouncycastle.openssl.PEMParser; +import org.bouncycastle.openssl.jcajce.JcaPEMKeyConverter; +import org.junit.Test; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.core.io.Resource; + +import java.io.File; +import java.io.IOException; +import java.io.Reader; +import java.io.StringReader; +import java.nio.charset.StandardCharsets; +import java.security.*; +import java.security.spec.InvalidKeySpecException; + +import static org.junit.Assert.assertThat; +import static org.hamcrest.Matchers.*; + +public class JWTTest { + @Value("classpath:testkey.p8") + Resource p8key; + @Test + public void testAppleClientSecret() throws NoSuchAlgorithmException, IOException, InvalidKeySpecException, NoSuchProviderException { + AppleClientSecretGenerator clientSecretGenerator = + new AppleClientSecretGenerator("example", "1", "2", p8key.getFilename()); + String secret = new String(clientSecretGenerator.getClientSecret().getBytes(), StandardCharsets.UTF_8); + String p8encodedData = FileUtils.readFileToString(new File(p8key.getFilename()), StandardCharsets.UTF_8); + Security.addProvider(new org.bouncycastle.jce.provider.BouncyCastleProvider()); + JcaPEMKeyConverter pemConverter = new JcaPEMKeyConverter(); + pemConverter.setProvider("BC"); + //EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(Base64.getDecoder().decode(p8encodedData)); + // Strips the "---- {BEGIN,END} {CERTIFICATE,PUBLIC/PRIVATE KEY} -----"-like header and footer lines, + // base64-decodes the body, + // then uses the proper key specification format to turn it into a JCA Key instance + final Reader pemReader = new StringReader(p8encodedData); + final PEMParser parser = new PEMParser(pemReader); + PrivateKey privateKey; + Object pemObj = parser.readObject(); + + privateKey = pemConverter.getPrivateKey((PrivateKeyInfo) pemObj); + + +// Generate public key from private key + KeyFactory keyFactory = KeyFactory.getInstance("ECDSA", "BC"); + ECParameterSpec ecSpec = ECNamedCurveTable.getParameterSpec("secp256r1"); + + ECPoint Q = ecSpec.getG().multiply(((ECPrivateKey)privateKey).getD()); + byte[] publicDerBytes = Q.getEncoded(false); + + ECPoint point = ecSpec.getCurve().decodePoint(publicDerBytes); + ECPublicKeySpec pubSpec = new ECPublicKeySpec(point, ecSpec); + ECPublicKey publicKeyGenerated = (ECPublicKey) keyFactory.generatePublic(pubSpec); + + Jws jwt = Jwts.parser() + .setSigningKey(publicKeyGenerated) + .parseClaimsJws(secret); + assertThat(jwt.getHeader().get("kid"), is("2")); + assertThat(jwt.getHeader().get("alg"), is("ES256")); + Claims claims = (Claims)jwt.getBody(); + assertThat(claims.get("iss"), is("1")); + assertThat(claims.get("sub"), is("example")); + assertThat(claims.get("aud"), is("https://appleid.apple.com")); + } +} diff --git a/src/test/resources/testkey.p8 b/src/test/resources/testkey.p8 new file mode 100644 index 00000000..1e5d0f98 --- /dev/null +++ b/src/test/resources/testkey.p8 @@ -0,0 +1,5 @@ +-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQg72/yb71r7uVirjpf +vjAPZ5aUR0si2c1yH6Lt/NlhWcWhRANCAAQydvu0D1+DTVM0/U0rbxHfkG3AswYw +hZZk58QvxSbxoBcmoLLMKAuaNBCVg+4I0xKvCfB0dkIjRATNpveON8y3 +-----END PRIVATE KEY----- -- cgit v1.2.3