/* * Copyright (C) 2008-2023, 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.www.controllers; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import com.github.scribejava.apis.*; import com.github.scribejava.core.builder.ServiceBuilder; import com.github.scribejava.core.model.*; import com.github.scribejava.core.oauth.AccessTokenRequestParams; import com.github.scribejava.core.oauth.AuthorizationUrlBuilder; import com.github.scribejava.core.oauth.OAuth20Service; import com.juick.model.ext.facebook.User; import com.juick.model.ext.vk.UsersResponse; import com.juick.service.*; import com.juick.service.security.entities.JuickUser; import com.juick.util.HttpBadRequestException; import jakarta.annotation.PostConstruct; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; 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; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Value; import org.springframework.security.authentication.RememberMeAuthenticationToken; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.RememberMeServices; import org.springframework.security.web.authentication.rememberme.AbstractRememberMeServices; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.*; import org.springframework.web.util.UriComponentsBuilder; import javax.inject.Inject; import java.io.IOException; import java.util.List; import java.util.Map; import java.util.Optional; import java.util.UUID; import java.util.concurrent.ExecutionException; import java.util.stream.Collectors; /** * * @author Ugnich Anton */ @Controller public class SocialLogin { private static final Logger logger = LoggerFactory.getLogger(SocialLogin.class); public static final String AUTH_ERROR = "SocialLogin.AuthenticationError"; @Value("${facebook_appid:appid}") private String FACEBOOK_APPID; @Value("${facebook_secret:secret}") private String FACEBOOK_SECRET; @Value("${ap_base_uri:http://localhost:8080/}") private String baseUri; private static final String TWITTER_VERIFY_URL = "https://api.twitter.com/2/users/me"; @Inject private ObjectMapper jsonMapper; private ServiceBuilder twitterBuilder; private OAuth20Service facebookAuthService, appleSignInService; @Inject private VKService vkService; @Inject private TwitterService twitterService; @Value("${telegram_token:secret}") private String telegramToken; @Value("${apple_app_id:appid}") private String appleApplicationId; @Inject private UserService userService; @Inject private EmailService emailService; @Inject private TelegramService telegramService; @Inject private AppleClientSecretGenerator clientSecretGenerator; @Inject private RememberMeServices rememberMeServices; private AuthorizationUrlBuilder authorizationUrlBuilder; @PostConstruct public void init() { ServiceBuilder facebookBuilder = new ServiceBuilder(FACEBOOK_APPID); UriComponentsBuilder redirectBuilder = UriComponentsBuilder.fromUriString(baseUri); String facebookRedirectUri = redirectBuilder.replacePath("/_fblogin").build().toUriString(); facebookAuthService = facebookBuilder.apiSecret(FACEBOOK_SECRET).callback(facebookRedirectUri) .defaultScope("email").build(FacebookApi.instance()); ServiceBuilder appleSignInBuilder = new ServiceBuilder(appleApplicationId); String appleSignInRedirectUri = redirectBuilder.replacePath("/_apple").build().toUriString(); appleSignInService = appleSignInBuilder.callback(appleSignInRedirectUri).defaultScope("email") .build(new AppleSignInApi(clientSecretGenerator, appleApplicationId)); } @GetMapping("/_fblogin") protected String doFacebookLogin(HttpServletRequest request, @RequestParam(required = false) String code, @RequestParam(required = false) String state, @RequestHeader(value = "referer", required = false) String referer, HttpServletResponse response, HttpSession session) throws IOException, ExecutionException, InterruptedException { if (StringUtils.isBlank(code)) { String fbstate = UUID.randomUUID().toString(); if (StringUtils.isBlank(state)) { state = Optional.ofNullable(referer).orElse("https://juick.com/"); } userService.addFacebookState(fbstate, state); return "redirect:" + facebookAuthService.getAuthorizationUrl(fbstate); } String redirectUrl = userService.verifyFacebookState(state); if (StringUtils.isEmpty(redirectUrl)) { logger.error("state is missing"); throw new HttpBadRequestException(); } OAuth2AccessToken token = facebookAuthService.getAccessToken(code); final OAuthRequest meRequest = new OAuthRequest(Verb.GET, "https://graph.facebook.com/me?fields=id,name,link,verified,email"); facebookAuthService.signRequest(token, meRequest); String graph = facebookAuthService.execute(meRequest).getBody(); if (StringUtils.isBlank(graph)) { logger.error("FACEBOOK GRAPH ERROR"); throw new HttpBadRequestException(); } User fb = jsonMapper.readValue(graph, User.class); long fbID = NumberUtils.toLong(fb.id(), 0); if (fbID == 0 || StringUtils.isBlank(fb.name())) { logger.error("Missing required fields, id: {}, name: {}", fbID, fb.name()); throw new HttpBadRequestException(); } Optional existingFacebookUser = userService.getUserByFacebookId(fbID); if (existingFacebookUser.isPresent()) { if (!userService.updateFacebookUser(fbID, token.getAccessToken(), fb.name())) { logger.error("error updating facebook user, id: {}, token: {}", fbID, token.getAccessToken()); throw new HttpBadRequestException(); } if (StringUtils.isNotEmpty(fb.email())) { logger.info("found {} for facebook user {}", fb.email(), fb.name()); Optional newFacebookUser = userService.getUserByFacebookId(fbID); if (!emailService.getEmails(newFacebookUser.get().getUid(), false).contains(fb.email())) { emailService.addEmail(newFacebookUser.get().getUid(), fb.email()); } } if (!existingFacebookUser.get().isBanned()) { Cookie c = new Cookie("hash", userService.getHashByUID(existingFacebookUser.get().getUid())); c.setMaxAge(50 * 24 * 60 * 60); response.addCookie(c); return "redirect:" + redirectUrl; } else { session.setAttribute(SocialLogin.AUTH_ERROR, "User is disabled"); return "redirect:/login"; } } else { if (!userService.createFacebookUser(fbID, state, token.getAccessToken(), fb.name())) { throw new HttpBadRequestException(); } return "redirect:/signup?type=fb&hash=" + state; } } @GetMapping("/_twitter") protected String doTwitterLogin(@RequestParam(required = false) String code, @RequestParam(required = false) String state, com.juick.model.User user, HttpServletRequest request) throws IOException, ExecutionException, InterruptedException { if (StringUtils.isBlank(code)) { state = UUID.randomUUID().toString(); authorizationUrlBuilder = twitterService.getTwitterAuthService().createAuthorizationUrlBuilder() .state(state) .initPKCE(); return "redirect:" + authorizationUrlBuilder.build(); } else { var token = twitterService.getTwitterAuthService().getAccessToken(AccessTokenRequestParams.create(code) .pkceCodeVerifier(authorizationUrlBuilder.getPkce().getCodeVerifier())); var me = new OAuthRequest(Verb.GET, TWITTER_VERIFY_URL); twitterService.getTwitterAuthService().signRequest(token, me); try (var response = twitterService.getTwitterAuthService().execute(me)) { if (response.isSuccessful()) { logger.info("Twitter response: {}", response.getBody()); JsonNode json = jsonMapper.readTree(response.getBody()); var screenName = json.get("data").get("username").asText(); if (userService.linkTwitterAccount(user, token.getAccessToken(), token.getRefreshToken(), screenName)) { return "redirect:https://juick.com/settings"; } else { throw new HttpBadRequestException(); } } else { logger.warn("Twitter error {}: {}", response.getCode(), response.getBody()); throw new HttpBadRequestException(); } } catch (Exception e) { logger.error("Twitter error", e); throw new HttpBadRequestException(); } } } @GetMapping("/_vklogin") protected String doVKLogin(@RequestParam(required = false) String code, @RequestParam(required = false) String state, @RequestHeader(value = "referer", required = false) String referer, @CookieValue(required = false) String vkstate, HttpServletResponse response) throws IOException, ExecutionException, InterruptedException { if (StringUtils.isBlank(code)) { vkstate = UUID.randomUUID().toString(); Cookie c = new Cookie("vkstate", vkstate); response.addCookie(c); return "redirect:" + vkService.getVkAuthService().getAuthorizationUrl(vkstate); } if (StringUtils.isBlank(vkstate) || !vkstate.equals(state)) { throw new HttpBadRequestException(); } else { Cookie c = new Cookie("vkstate", "-"); c.setMaxAge(0); response.addCookie(c); } OAuth2AccessToken token = vkService.getVkAuthService().getAccessToken(code); OAuthRequest meRequest = new OAuthRequest(Verb.GET, "https://api.vk.com/method/users.get?fields=screen_name&v=5.131"); vkService.getVkAuthService().signRequest(token, meRequest); try (Response vkResponse = vkService.getVkAuthService().execute(meRequest)) { if (vkResponse.isSuccessful()) { String graph = vkResponse.getBody(); com.juick.model.ext.vk.User jsonUser = jsonMapper.readValue(graph, UsersResponse.class).users().stream() .findFirst().orElseThrow(HttpBadRequestException::new); String vkName = jsonUser.firstName() + " " + jsonUser.lastName(); String vkLink = jsonUser.screenName(); if (vkName.length() == 1 || StringUtils.isBlank(vkLink)) { logger.error("vk user error"); throw new HttpBadRequestException(); } long vkID = NumberUtils.toLong(jsonUser.id(), 0); int uid = userService.getUIDbyVKID(vkID); if (uid > 0) { userService.updateVkUser(vkID, token.getAccessToken(), vkName, vkLink); Cookie c = new Cookie("hash", userService.getHashByUID(uid)); c.setMaxAge(50 * 24 * 60 * 60); response.addCookie(c); return "redirect:/" + Optional.ofNullable(referer).orElse(StringUtils.EMPTY); } else { String loginhash = UUID.randomUUID().toString(); if (!userService.createVKUser(vkID, loginhash, token.getAccessToken(), vkName, vkLink)) { logger.error("create vk user error"); throw new HttpBadRequestException(); } return "redirect:/signup?type=vk&hash=" + loginhash; } } else { logger.error("vk error {}: {}", vkResponse.getCode(), vkResponse.getBody()); throw new HttpBadRequestException(); } } } @GetMapping("/_tglogin") public String doDurovLogin(@RequestParam Map params, @RequestParam String hash, @RequestHeader(value = "referer", required = false) String referer, HttpServletRequest request, HttpServletResponse response) { String dataCheckString = params.entrySet().stream().filter(p -> !p.getKey().equals("hash")) .sorted(Map.Entry.comparingByKey()).map(p -> p.getKey() + "=" + p.getValue()) .collect(Collectors.joining("\n")); byte[] secretKey = DigestUtils.sha256(telegramToken); String resultString = new HmacUtils(HmacAlgorithms.HMAC_SHA_256, secretKey).hmacHex(dataCheckString); if (hash.equals(resultString)) { long tgUser = Long.parseLong(params.get("id")); var user = userService.getUserByTelegramId(tgUser); if (user.isPresent()) { var authentication = new RememberMeAuthenticationToken( ((AbstractRememberMeServices) rememberMeServices).getKey(), new JuickUser(user.get()), JuickUser.USER_AUTHORITY); SecurityContextHolder.getContext().setAuthentication(authentication); rememberMeServices.loginSuccess(request, response, authentication); return "redirect:" + Optional.ofNullable(referer).orElse(StringUtils.EMPTY); } else { String username = StringUtils.defaultString(params.get("username"), params.get("first_name")); List chats = telegramService.getAnonymous(); if (!chats.contains(tgUser)) { logger.info("added chat with {}", username); telegramService.createTelegramUser(tgUser, username); } return "redirect:/signup?type=durov&hash=" + userService.getSignUpHashByTelegramID(tgUser, username); } } else { logger.warn("invalid tg hash {} for {}", resultString, hash); } throw new HttpBadRequestException(); } @GetMapping("/_apple") public String doAppleLogin(@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, HttpSession session) throws InterruptedException, ExecutionException, IOException { OAuth2AccessToken token = appleSignInService.getAccessToken(body.get("code")); var jsonNode = jsonMapper.readTree(token.getRawResponse()); var idToken = jsonNode.get("id_token").textValue(); logger.info("Token: {}", idToken); AppleSignInApi api = (AppleSignInApi) appleSignInService.getApi(); var email = api.validateToken(idToken); if (email.isPresent()) { com.juick.model.User user = userService.getUserByEmail(email.get()); if (!user.isAnonymous()) { if (!user.isBanned()) { Cookie c = new Cookie("hash", userService.getHashByUID(user.getUid())); c.setMaxAge(50 * 24 * 60 * 60); response.addCookie(c); return "redirect:/"; } else { session.setAttribute(SocialLogin.AUTH_ERROR, "User is disabled"); return "redirect:/login"; } } else { String verificationCode = RandomStringUtils.randomAlphanumeric(8).toUpperCase(); emailService.addVerificationCode(null, email.get(), verificationCode); return "redirect:/signup?type=email&hash=" + verificationCode; } } throw new HttpBadRequestException(); } }