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 --- .../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 ++++++++++++++++++++- 5 files changed, 303 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 (limited to 'src/main/java') 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(); + } } -- cgit v1.2.3