Skip to content

Commit

Permalink
EDU-6759: LinkedIn login
Browse files Browse the repository at this point in the history
Merge-request: EDU-MR-3368
Merged-by: Sviatoslav Naiden <[email protected]>
  • Loading branch information
DonHalkon authored and qodana-bot committed Jul 24, 2024
1 parent 3d21db4 commit 94f61c8
Show file tree
Hide file tree
Showing 31 changed files with 584 additions and 90 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ intellij-plugin/Edu-Kotlin/resources/twitter/kotlin_koans/oauth_twitter.properti
intellij-plugin/educational-core/resources/twitter/oauth_twitter.properties
intellij-plugin/educational-core/resources/stepik/stepik.properties
intellij-plugin/educational-core/resources/hyperskill/hyperskill-oauth.properties
intellij-plugin/educational-core/resources/linkedin/linkedin-oauth.properties
intellij-plugin/educational-core/resources/marketplace/marketplace-oauth.properties
#AES key
edu-format/resources/aes/aes.properties
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,7 @@
package com.jetbrains.edu.learning.authUtils

import retrofit2.Call
import retrofit2.http.Field
import retrofit2.http.FormUrlEncoded
import retrofit2.http.POST
import retrofit2.http.Url
import retrofit2.http.*

interface EduOAuthEndpoints {
@POST
Expand All @@ -16,7 +13,7 @@ interface EduOAuthEndpoints {
@Field("redirect_uri") redirectUri: String,
@Field("code") code: String,
@Field("grant_type") grantType: String,
@Field("code_verifier") codeVerifier: String,
@FieldMap codeVerifier: Map<String, String>
): Call<TokenInfo>

@POST
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ abstract class OAuthAccount<UInfo : UserInfo> : Account<UInfo> {
this.userInfo = userInfo
}

constructor(userInfo: UInfo, tokenExpiresIn: Long) {
this.userInfo = userInfo
this.tokenExpiresIn = tokenExpiresIn
}

override fun isUpToDate() = TokenInfo().apply { expiresIn = tokenExpiresIn }.isUpToDate()

fun getAccessToken(): String? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import com.jetbrains.edu.learning.StudyTaskManager
import com.jetbrains.edu.learning.courseFormat.CheckStatus
import com.jetbrains.edu.learning.courseFormat.CheckStatus.*
import com.jetbrains.edu.learning.courseFormat.tasks.Task
import com.jetbrains.edu.learning.twitter.TwitterPluginConfigurator
import com.jetbrains.edu.learning.twitter.TwitterUtils
import com.jetbrains.edu.learning.socialmedia.twitter.TwitterPluginConfigurator
import com.jetbrains.edu.learning.socialmedia.twitter.TwitterUtils
import org.jetbrains.annotations.NonNls
import java.nio.file.Path

class KtTwitterConfigurator : TwitterPluginConfigurator {
override fun askToTweet(project: Project, solvedTask: Task, statusBeforeCheck: CheckStatus): Boolean {
override fun askToPost(project: Project, solvedTask: Task, statusBeforeCheck: CheckStatus): Boolean {
val course = StudyTaskManager.getInstance(project).course ?: return false
if (course.name == "Kotlin Koans") {
return solvedTask.status == Solved &&
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@
</extensionPoint>

<extensionPoint qualifiedName="Educational.twitterPluginConfigurator"
interface="com.jetbrains.edu.learning.twitter.TwitterPluginConfigurator"
interface="com.jetbrains.edu.learning.socialmedia.twitter.TwitterPluginConfigurator"
dynamic="true"/>
<extensionPoint qualifiedName="Educational.remoteTaskChecker"
interface="com.jetbrains.edu.learning.checker.remote.RemoteTaskChecker"
Expand Down Expand Up @@ -362,6 +362,7 @@
<applicationService serviceImplementation="com.jetbrains.edu.learning.coursera.CourseraSettings"/>
<httpRequestHandler implementation="com.jetbrains.edu.learning.stepik.builtInServer.StepikRestService"/>
<httpRequestHandler implementation="com.jetbrains.edu.learning.taskToolWindow.ui.EduToolsResourcesRequestHandler"/>
<httpRequestHandler implementation="com.jetbrains.edu.learning.socialmedia.linkedIn.LinkedInRestService"/>
<registryKey key="edu.course.update.check.interval"
description="Sets is course up to date check interval in seconds"
defaultValue="18000"/>
Expand Down Expand Up @@ -439,7 +440,7 @@

<optionsProvider instance="com.jetbrains.edu.learning.stepik.StepikOptions"/>
<optionsProvider instance="com.jetbrains.edu.learning.coursera.CourseraOptions"/>
<checkListener implementation="com.jetbrains.edu.learning.twitter.TwitterAction"/>
<checkListener implementation="com.jetbrains.edu.learning.socialmedia.twitter.TwitterAction"/>
<checkListener implementation="com.jetbrains.edu.learning.statistics.PostFeedbackCheckListener"/>
<checkListener implementation="com.jetbrains.edu.learning.command.validation.ValidationCheckListener"/>
<coursesPlatformProviderFactory id="Marketplace" order="first"
Expand Down Expand Up @@ -489,7 +490,8 @@

<!--educator-->
<optionsProvider instance="com.jetbrains.edu.coursecreator.settings.CCOptions"/>
<optionsProvider instance="com.jetbrains.edu.learning.twitter.TwitterOptionsProvider"/>
<optionsProvider instance="com.jetbrains.edu.learning.socialmedia.twitter.TwitterOptionsProvider"/>
<optionsProvider instance="com.jetbrains.edu.learning.socialmedia.linkedIn.LinkedInOptionsProvider"/>

<pathMacroProvider implementation="com.jetbrains.edu.learning.checker.TaskRunConfigurationPathMacroProvider"/>
</extensions>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
<html>
<head>
<title>Redirecting to LinkedIn</title>
<meta http-equiv="refresh" content="0.1; URL=https://www.linkedin.com/feed/">
<meta name="keywords" content="automatic redirection">
</head>

<body>
If your browser doesn't automatically redirect within a few seconds,
you may want to go to
<a href="https://www.linkedin.com/feed/">the destination</a> manually.
</body>
</html>
Original file line number Diff line number Diff line change
Expand Up @@ -847,6 +847,13 @@ label.wrap=Wrap

link.label.codeforces.view.all.contests=View all contests...

# Post to LinkedIn
linkedin.ask.to.post=Prompt to post achievements
linkedin.configurable.name=LinkedIn
linkedin.loading.posting=Posting
linkedin.post.button.text=Post


liveTemplate.addHint.description=Insert hint block for task description

# Loading solution 3 of 25
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,10 @@ abstract class EduOAuthCodeFlowConnector<Account : OAuthAccount<*>, SpecificUser
error("Failed to refresh token")
}

protected fun retrieveLoginToken(code: String, redirectUri: String): TokenInfo? {
protected fun retrieveLoginToken(code: String, redirectUri: String, codeVerifierFieldName: String = "code_verifier"): TokenInfo? {
val codeVerifierField = mapOf(codeVerifierFieldName to codeVerifier)
val response = getEduOAuthEndpoints()
.getTokens(baseOAuthTokenUrl, clientId, clientSecret, redirectUri, code, OAuthUtils.GrantType.AUTHORIZATION_CODE, codeVerifier)
.getTokens(baseOAuthTokenUrl, clientId, clientSecret, redirectUri, code, OAuthUtils.GrantType.AUTHORIZATION_CODE, codeVerifierField)
.executeHandlingExceptions()
return response?.body()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.jetbrains.edu.learning.socialmedia

import com.intellij.openapi.components.BaseState
import com.intellij.openapi.components.SimplePersistentStateComponent
import com.jetbrains.edu.learning.isUnitTestMode

abstract class SocialMediaSettings<out T : SocialMediaSettings.SocialMediaSettingsState>(state: T) :
SimplePersistentStateComponent<@UnsafeVariance T>(state) {

// Don't use property delegation like `var askToTweet by state::askToPost`.
// It doesn't work because `state` may change but delegation keeps the initial state object
var askToPost: Boolean
get() = state.askToPost
set(value) {
state.askToPost = value
}

var userId: String
get() {
return state.userId ?: getDefaultUserId()
}
set(userId) {
state.userId = userId
}

abstract fun getDefaultUserId(): String

open class SocialMediaSettingsState : BaseState() {
var userId by string()
var askToPost by property(!isUnitTestMode)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package com.jetbrains.edu.learning.socialmedia.linkedIn

import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.databind.module.SimpleModule
import com.intellij.openapi.components.Service
import com.intellij.openapi.components.service
import com.jetbrains.edu.learning.api.EduOAuthCodeFlowConnector
import com.jetbrains.edu.learning.authUtils.ConnectorUtils
import com.jetbrains.edu.learning.network.executeHandlingExceptions
import org.apache.http.client.utils.URIBuilder

@Service
class LinkedInConnector : EduOAuthCodeFlowConnector<LinkedInAccount, LinkedInUserInfo>() {
override val authorizationUrlBuilder: URIBuilder
get() = URIBuilder(LINKEDIN_BASE_WWW_URL)
.setPath("/oauth/v2/authorization")
.addParameter(CLIENT_ID_PARAM_NAME, CLIENT_ID)
.addParameter(GRANT_TYPE, "code")
.addParameter(REDIRECT_URL, getRedirectUri())
.addParameter(RESPONSE_TYPE, "code")
.addParameter(SCOPE, "w_member_social openid profile")

override val baseOAuthTokenUrl: String
get() = "/oauth/v2/accessToken"

override val baseUrl: String = LINKEDIN_BASE_WWW_URL
override val clientId: String = CLIENT_ID
override val clientSecret: String = CLIENT_SECRET
override val objectMapper: ObjectMapper by lazy {
ConnectorUtils.createRegisteredMapper(SimpleModule())
}
override val platformName: String = LINKEDIN

override var account: LinkedInAccount?
get() {
return LinkedInSettings.getInstance().account
}
set(account) {
LinkedInSettings.getInstance().account = account
}

override fun getUserInfo(account: LinkedInAccount, accessToken: String?): LinkedInUserInfo? {
val response =
getEndpoints<LinkedInEndpoints>(account, accessToken, baseUrl = LINKEDIN_API_URL).getCurrentUserInfo().executeHandlingExceptions()
return response?.body()
}

@Synchronized
override fun login(code: String): Boolean {
val tokenInfo = retrieveLoginToken(code, getRedirectUri(), codeVerifierFieldName = CODE_VERIFIER_PARAM_NAME) ?: return false
val account = LinkedInAccount(tokenInfo.expiresIn)
val currentUser = getUserInfo(account, tokenInfo.accessToken) ?: return false
account.userInfo = currentUser
account.saveTokens(tokenInfo)
this.account = account
notifyUserLoggedIn()
return true
}

companion object {
private val CLIENT_ID: String = LinkedInOAuthBundle.value("linkedInClientId")
private val CLIENT_SECRET: String = LinkedInOAuthBundle.value("linkedInClientSecret")
private const val LINKEDIN_API_URL = "https://api.linkedin.com"
const val LINKEDIN_BASE_WWW_URL = "https://www.linkedin.com"
const val LINKEDIN = "LinkedIn"

private const val CLIENT_ID_PARAM_NAME = "client_id"
private const val CODE_VERIFIER_PARAM_NAME = "state"
private const val GRANT_TYPE = "grant_type"
private const val REDIRECT_URL = "redirect_uri"
private const val RESPONSE_TYPE = "response_type"
private const val SCOPE = "scope"


fun getInstance(): LinkedInConnector = service()
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.jetbrains.edu.learning.socialmedia.linkedIn

import retrofit2.Call
import retrofit2.http.GET

interface LinkedInEndpoints {
@GET("/v2/userinfo")
fun getCurrentUserInfo(): Call<LinkedInUserInfo>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package com.jetbrains.edu.learning.socialmedia.linkedIn

import com.jetbrains.edu.learning.messages.EduPropertiesBundle
import org.jetbrains.annotations.NonNls
import org.jetbrains.annotations.PropertyKey

@NonNls
private const val BUNDLE_NAME = "linkedin.linkedin-oauth"

object LinkedInOAuthBundle : EduPropertiesBundle(BUNDLE_NAME) {
fun value(@PropertyKey(resourceBundle = BUNDLE_NAME) key: String): String {
return valueOrEmpty(key)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.jetbrains.edu.learning.socialmedia.linkedIn

import com.intellij.openapi.options.BoundConfigurable
import com.intellij.openapi.ui.DialogPanel
import com.intellij.ui.dsl.builder.bindSelected
import com.intellij.ui.dsl.builder.panel
import com.jetbrains.edu.learning.messages.EduCoreBundle
import com.jetbrains.edu.learning.settings.OptionsProvider

class LinkedInOptionsProvider : BoundConfigurable(EduCoreBundle.message("linkedin.configurable.name")), OptionsProvider {

override fun createPanel(): DialogPanel = panel {
group(displayName) {
row {
checkBox(EduCoreBundle.message("linkedin.ask.to.post"))
.bindSelected(LinkedInSettings.getInstance()::askToPost)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
package com.jetbrains.edu.learning.socialmedia.linkedIn

import com.intellij.util.io.origin
import com.jetbrains.edu.learning.*
import com.jetbrains.edu.learning.authUtils.*
import com.jetbrains.edu.learning.courseFormat.EduFormatNames.CODE_ARGUMENT
import com.jetbrains.edu.learning.courseGeneration.GeneratorUtils.getInternalTemplateText
import io.netty.channel.ChannelHandlerContext
import io.netty.handler.codec.http.*
import org.jetbrains.io.send
import java.io.IOException
import java.lang.reflect.InvocationTargetException

class LinkedInRestService : OAuthRestService("LinkedIn") {

@Throws(InterruptedException::class, InvocationTargetException::class)
override fun isHostTrusted(request: FullHttpRequest, urlDecoder: QueryStringDecoder): Boolean {
return if (request.method() === HttpMethod.GET
// If isOriginAllowed is `false` check if it is a valid oAuth request with empty origin
&& ((isOriginAllowed(request) === OriginCheckResult.ALLOW || LinkedInConnector.getInstance()
.isValidOAuthRequest(request, urlDecoder)))
) {
true
}
else {
super.isHostTrusted(request, urlDecoder)
}
}

@Throws(IOException::class)
override fun execute(
urlDecoder: QueryStringDecoder,
request: FullHttpRequest,
context: ChannelHandlerContext
): String? {
val uri = urlDecoder.uri()

if (LinkedInConnector.getInstance().getOAuthPattern("\\?error=(\\w+)").matcher(uri).matches()) {
return sendErrorResponse(request, context, "Failed to log in")
}

if (LinkedInConnector.getInstance().getOAuthPattern().matcher(uri).matches()) {
val code = getStringParameter(CODE_ARGUMENT, urlDecoder)!! // cannot be null because of pattern
val success = LinkedInConnector.getInstance().login(code)
if (success) {
LOG.info("$platformName: OAuth code is handled")
val pageContent = getInternalTemplateText("linkedin.redirectPage.html")
createResponse(pageContent).send(context.channel(), request)
return null
}
return sendErrorResponse(request, context, "Failed to log in using provided code")
}

sendStatus(HttpResponseStatus.BAD_REQUEST, false, context.channel())
return "Unknown command: $uri"
}

override fun getServiceName(): String = LinkedInConnector.getInstance().serviceName

override fun isOriginAllowed(request: HttpRequest): OriginCheckResult {
val originAllowed = super.isOriginAllowed(request)
if (originAllowed == OriginCheckResult.FORBID) {
val origin = request.origin ?: return OriginCheckResult.FORBID
if (origin == LinkedInConnector.LINKEDIN_BASE_WWW_URL) {
return OriginCheckResult.ALLOW
}
}
return originAllowed
}
}
Loading

0 comments on commit 94f61c8

Please sign in to comment.