Merge pull request #29 from H-PixelDroid/oath

Oauth
This commit is contained in:
Samuel Dietz 2020-03-08 10:35:46 +01:00 committed by GitHub
commit 1dbb1c019c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 493 additions and 41 deletions

View File

@ -4,6 +4,7 @@ apply plugin: 'kotlin-android-extensions'
apply plugin: 'jacoco'
android {
compileSdkVersion 29
buildToolsVersion "29.0.3"
compileOptions {
@ -18,6 +19,12 @@ android {
versionName "1.0"
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
testInstrumentationRunnerArguments clearPackageData: 'true'
}
sourceSets {
main.java.srcDirs += 'src/main/java'
test.java.srcDirs += 'src/test/java'
androidTest.java.srcDirs += 'src/androidTest/java'
}
buildTypes {
@ -29,6 +36,10 @@ android {
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
}
testOptions {
animationsDisabled true
}
}
@ -44,16 +55,24 @@ dependencies {
implementation 'com.squareup.retrofit2:adapter-rxjava2:2.7.1'
implementation 'io.reactivex.rxjava2:rxjava:2.2.16'
implementation 'io.reactivex.rxjava2:rxandroid:2.1.1'
implementation "androidx.browser:browser:1.2.0"
implementation 'com.google.android.material:material:1.1.0'
implementation ("com.github.bumptech.glide:glide:4.11.0") {
exclude group: "com.android.support"
}
testImplementation "com.github.tomakehurst:wiremock-jre8:2.26.2"
implementation "com.github.bumptech.glide:okhttp-integration:4.11.0"
testImplementation "com.github.tomakehurst:wiremock-jre8:2.26.3"
testImplementation "com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0"
testImplementation 'junit:junit:4.13'
androidTestImplementation 'androidx.test.ext:junit:1.1.1'
androidTestImplementation 'androidx.test.espresso:espresso-core:3.2.0'
}
androidTestImplementation 'androidx.test.espresso:espresso-intents:3.2.0'
}
tasks.withType(Test) {
jacoco.includeNoLocationClasses = true
@ -70,9 +89,13 @@ task jacocoTestReport(type: JacocoReport, dependsOn: ['testDebugUnitTest', 'crea
def fileFilter = ['**/R.class', '**/R$*.class', '**/BuildConfig.*', '**/Manifest*.*', '**/*Test*.*', 'android/**/*.*']
def kotlinDebugTree = fileTree(dir: "$project.buildDir/tmp/kotlin-classes/debug", excludes: fileFilter)
def mainSrc = "$project.projectDir/src/main/java"
sourceDirectories = files([mainSrc])
classDirectories = files([kotlinDebugTree])
executionData = fileTree(dir: project.buildDir, includes: [
'jacoco/testDebugUnitTest.exec', 'outputs/code-coverage/debugAndroidTest/connected/*coverage.ec'
])
getSourceDirectories().from(files([mainSrc]))
getClassDirectories().from(files([kotlinDebugTree]))
getExecutionData().from(fileTree(dir: project.buildDir, includes: [
'outputs/code_coverage/debugAndroidTest/connected/*coverage.ec',
'jacoco/testDebugUnitTest.exec'
]))
}

View File

@ -0,0 +1,100 @@
package com.h.pixeldroid
import android.app.Activity
import android.content.Intent
import android.content.Intent.ACTION_VIEW
import android.net.Uri
import androidx.test.espresso.Espresso.onView
import androidx.test.espresso.action.ViewActions
import androidx.test.espresso.action.ViewActions.click
import androidx.test.espresso.assertion.ViewAssertions.matches
import androidx.test.espresso.intent.Intents.intended
import androidx.test.espresso.intent.matcher.IntentMatchers.hasAction
import androidx.test.espresso.intent.matcher.IntentMatchers.hasDataString
import androidx.test.espresso.intent.rule.IntentsTestRule
import androidx.test.espresso.matcher.ViewMatchers.*
import androidx.test.ext.junit.rules.ActivityScenarioRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.rule.ActivityTestRule
import org.hamcrest.CoreMatchers.*
import org.hamcrest.Matcher
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* Instrumented test, which will execute on an Android device.
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
@RunWith(AndroidJUnit4::class)
class LoginInstrumentedTest {
@get:Rule
var activityRule: ActivityScenarioRule<LoginActivity>
= ActivityScenarioRule(LoginActivity::class.java)
@Test
fun clickConnect() {
onView(withId(R.id.connect_instance_button)).check(matches(withText("Connect")))
}
@Test
fun invalidURL() {
onView(withId(R.id.editText)).perform(ViewActions.replaceText("/jdi"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(click())
onView(withId(R.id.editText)).check(matches(hasErrorText("Invalid domain")))
}
@Test
fun notPixelfedInstance() {
onView(withId(R.id.editText)).perform(ViewActions.replaceText("localhost"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(click())
onView(withId(R.id.editText)).check(matches(hasErrorText("Could not register the application with this server")))
}
}
@RunWith(AndroidJUnit4::class)
class LoginCheckIntent {
@get:Rule
val intentsTestRule = IntentsTestRule(LoginActivity::class.java)
@Test
fun launchesIntent() {
val expectedIntent: Matcher<Intent> = allOf(
hasAction(ACTION_VIEW),
hasDataString(containsString("pixelfed.social"))
)
onView(withId(R.id.editText)).perform(ViewActions.replaceText("pixelfed.social"), ViewActions.closeSoftKeyboard())
onView(withId(R.id.connect_instance_button)).perform(click())
Thread.sleep(5000)
intended(expectedIntent)
}
}
@RunWith(AndroidJUnit4::class)
class AfterIntent {
@get:Rule
val rule = ActivityTestRule(LoginActivity::class.java)
private var launchedActivity: Activity? = null
@Before
fun setup() {
val intent = Intent(ACTION_VIEW, Uri.parse("oauth2redirect://com.h.pixeldroid?code=sdfdqsf"))
launchedActivity = rule.launchActivity(intent)
}
@Test
fun usesIntent() {
Thread.sleep(5000)
onView(withId(R.id.editText)).check(matches(
anyOf(hasErrorText("Error getting token"),
hasErrorText("Could not authenticate"))))
}
}

View File

@ -25,10 +25,4 @@ class ProfileTest {
onView(withId(R.id.followers)).check(matches(withText("Followers")))
}
@Test
fun testAccountNameTextView() {
onView(withId(R.id.button)).perform(click())
sleep((1000 * 5))
onView(withId(R.id.accountName)).check(matches(not(withText("No Username"))))
}
}
}

View File

@ -9,6 +9,19 @@
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/AppTheme">
<activity
android:name=".LoginActivity"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="${applicationId}"
android:scheme="@string/auth_scheme" />
</intent-filter>
</activity>
<activity android:name=".ProfileActivity"></activity>
<activity android:name=".MainActivity">
<intent-filter>

View File

@ -0,0 +1,174 @@
package com.h.pixeldroid
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.content.SharedPreferences
import android.net.Uri
import android.os.Bundle
import android.util.Log
import androidx.appcompat.app.AppCompatActivity
import androidx.browser.customtabs.CustomTabsIntent
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.Application
import com.h.pixeldroid.objects.Token
import kotlinx.android.synthetic.main.activity_login.*
import okhttp3.HttpUrl
import retrofit2.Call
import retrofit2.Callback
import retrofit2.Response
class LoginActivity : AppCompatActivity() {
private lateinit var OAUTH_SCHEME: String
private val PACKAGE_ID = BuildConfig.APPLICATION_ID
private val SCOPE = "read write follow"
private lateinit var APP_NAME: String
private lateinit var preferences: SharedPreferences
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_login)
connect_instance_button.setOnClickListener { onClickConnect() }
APP_NAME = getString(R.string.app_name)
OAUTH_SCHEME = getString(R.string.auth_scheme)
preferences = getSharedPreferences(
"$PACKAGE_ID.pref", Context.MODE_PRIVATE
)
}
override fun onStart(){
super.onStart()
val url = intent.data
if (url == null || !url.toString().startsWith("$OAUTH_SCHEME://$PACKAGE_ID")) return
val code = url.getQueryParameter("code")
authenticate(code)
}
private fun onClickConnect() {
connect_instance_button.isEnabled = false
val normalizedDomain = normalizeDomain(editText.text.toString())
try{
HttpUrl.Builder().host(normalizedDomain).scheme("https").build()
} catch (e: IllegalArgumentException) {
return failedRegistration(getString(R.string.invalid_domain))
}
preferences.edit()
.putString("domain", normalizedDomain)
.apply()
registerAppToServer(normalizedDomain)
}
private fun normalizeDomain(domain: String): String {
var d = domain.replace("http://", "")
d = d.replace("https://", "")
return d.trim(Char::isWhitespace)
}
private fun registerAppToServer(normalizedDomain: String) {
val callback = object : Callback<Application> {
override fun onResponse(call: Call<Application>, response: Response<Application>) {
if (!response.isSuccessful) {
return failedRegistration()
}
val credentials = response.body()
val clientId = credentials?.client_id ?: return failedRegistration()
val clientSecret = credentials.client_secret
preferences.edit()
.putString("clientID", clientId)
.putString("clientSecret", clientSecret)
.apply()
promptOAuth(normalizedDomain, clientId)
}
override fun onFailure(call: Call<Application>, t: Throwable) {
return failedRegistration()
}
}
PixelfedAPI.create("https://$normalizedDomain").registerApplication(
APP_NAME,"$OAUTH_SCHEME://$PACKAGE_ID", SCOPE
).enqueue(callback)
}
private fun promptOAuth(normalizedDomain: String, client_id: String) {
val url = "https://$normalizedDomain/oauth/authorize?" +
"client_id" + "=" + client_id + "&" +
"redirect_uri" + "=" + "$OAUTH_SCHEME://$PACKAGE_ID" + "&" +
"response_type=code" + "&" +
"scope=$SCOPE"
val intent = CustomTabsIntent.Builder().build()
try {
intent.launchUrl(this, Uri.parse(url))
} catch (e: ActivityNotFoundException) {
val browserIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url))
if (browserIntent.resolveActivity(packageManager) != null) {
startActivity(browserIntent)
} else {
return failedRegistration(getString(R.string.browser_launch_failed))
}
}
connect_instance_button.isEnabled = true
}
private fun authenticate(code: String?) {
// Get previous values from preferences
val domain = preferences.getString("domain", "")
val clientId = preferences.getString("clientID", "")
val clientSecret = preferences.getString("clientSecret", "")
if (code == null || domain.isNullOrEmpty() || clientId.isNullOrEmpty() || clientSecret.isNullOrEmpty()) {
return failedRegistration(getString(R.string.auth_failed))
}
//Successful authorization
val callback = object : Callback<Token> {
override fun onResponse(call: Call<Token>, response: Response<Token>) {
if (!response.isSuccessful || response.body() == null) {
return failedRegistration(getString(R.string.token_error))
}
authenticationSuccessful(domain, response.body()!!.access_token)
}
override fun onFailure(call: Call<Token>, t: Throwable) {
return failedRegistration(getString(R.string.token_error))
}
}
PixelfedAPI.create("https://$domain")
.obtainToken(
clientId, clientSecret, "$OAUTH_SCHEME://$PACKAGE_ID", SCOPE, code,
"authorization_code"
).enqueue(callback)
}
private fun authenticationSuccessful(domain: String, accessToken: String) {
preferences.edit().putString("accessToken", accessToken).apply()
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
finish()
}
private fun failedRegistration(message: String =
getString(R.string.registration_failed)){
connect_instance_button.isEnabled = true
editText.error = message
}
}

View File

@ -11,6 +11,10 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
val button_login = findViewById<Button>(R.id.button_start_login)
button_login.setOnClickListener((View.OnClickListener {
val intent = Intent(this, LoginActivity::class.java)
startActivity(intent) }))
val button = findViewById<Button>(R.id.button)
button.setOnClickListener((View.OnClickListener {

View File

@ -1,12 +1,18 @@
package com.h.pixeldroid.api
import com.h.pixeldroid.objects.*
import com.h.pixeldroid.objects.Application
import com.h.pixeldroid.objects.Status
import com.h.pixeldroid.objects.Token
import retrofit2.Call
import retrofit2.Retrofit
import retrofit2.adapter.rxjava2.RxJava2CallAdapterFactory
import retrofit2.converter.gson.GsonConverterFactory
import retrofit2.http.FormUrlEncoded
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Query
import retrofit2.http.Field
/*
Implements the Pixelfed API
@ -17,15 +23,36 @@ import retrofit2.http.Query
interface PixelfedAPI {
@FormUrlEncoded
@POST("/api/v1/apps")
fun registerApplication(
@Field("client_name") client_name: String,
@Field("redirect_uris") redirect_uris: String,
@Field("scopes") scopes: String? = null,
@Field("website") website: String? = null
): Call<Application>
@FormUrlEncoded
@POST("/oauth/token")
fun obtainToken(
@Field("client_id") client_id: String,
@Field("client_secret") client_secret: String,
@Field("redirect_uri") redirect_uri: String,
@Field("scope") scope: String? = "read",
@Field("code") code: String? = null,
@Field("grant_type") grant_type: String? = null
): Call<Token>
@GET("/api/v1/timelines/public")
fun timelinePublic(
@Query("local") local: Boolean?,
@Query("max_id") max_id: String?,
@Query("since_id") since_id: String?,
@Query("min_id") min_id: String?,
@Query("limit") limit: Int?
@Query("local") local: Boolean? = null,
@Query("max_id") max_id: String? = null,
@Query("since_id") since_id: String? = null,
@Query("min_id") min_id: String? = null,
@Query("limit") limit: Int? = null
): Call<List<Status>>
companion object {
fun create(baseUrl: String): PixelfedAPI {
return Retrofit.Builder()

View File

@ -5,5 +5,8 @@ data class Application (
val name: String,
//Optional attributes
val website: String? = null,
val vapid_key: String? = null
val vapid_key: String? = null,
//Client Attributes
val client_id: String? = null,
val client_secret: String? = null
)

View File

@ -0,0 +1,8 @@
package com.h.pixeldroid.objects
data class Token(
val access_token: String,
val token_type: String,
val scope: String,
val created_at: Int
)

View File

@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context=".LoginActivity">
<Button
android:id="@+id/connect_instance_button"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="36dp"
android:text="Connect"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.498"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/domainTextInputLayout" />
<com.google.android.material.textfield.TextInputLayout
android:id="@+id/domainTextInputLayout"
android:layout_width="250dp"
android:layout_height="wrap_content"
android:hint="Domain of your instance"
app:errorEnabled="true"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.40">
<com.google.android.material.textfield.TextInputEditText
android:id="@+id/editText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ems="10"
android:inputType="textUri" />
</com.google.android.material.textfield.TextInputLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -6,11 +6,22 @@
android:layout_height="match_parent"
tools:context=".MainActivity">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical">
android:orientation="vertical"
tools:layout_editor_absoluteX="67dp"
tools:layout_editor_absoluteY="299dp">
<Button
android:id="@+id/button_start_login"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="80dp"
android:text="start login" />
<Button
android:id="@+id/button"
android:layout_width="wrap_content"
@ -18,5 +29,4 @@
android:text="Show a profile" />
</LinearLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

View File

@ -0,0 +1,5 @@
<resources>
<!-- Default screen margins, per the Android Design guidelines. -->
<dimen name="activity_horizontal_margin">16dp</dimen>
<dimen name="activity_vertical_margin">16dp</dimen>
</resources>

View File

@ -1,3 +1,19 @@
<resources>
<string name="app_name">PixelDroid</string>
<string name="title_activity_login">Sign in</string>
<string name="prompt_email">Email</string>
<string name="prompt_password">Password</string>
<string name="action_sign_in">Sign in or register</string>
<string name="action_sign_in_short">Sign in</string>
<string name="welcome">"Welcome !"</string>
<string name="invalid_username">Not a valid username</string>
<string name="invalid_password">Password must be >5 characters</string>
<string name="login_failed">"Login failed"</string>
<string name="invalid_domain">"Invalid domain"</string>
<string name="registration_failed">"Could not register the application with this server"</string>
<string name="browser_launch_failed">"Could not launch a browser, do you have one?"</string>
<string name="auth_failed">"Could not authenticate"</string>
<string name="token_error">"Error getting token"</string>
</resources>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="auth_scheme" translatable="false">oauth2redirect</string>
</resources>

View File

@ -5,7 +5,6 @@ import com.github.tomakehurst.wiremock.junit.WireMockRule
import com.h.pixeldroid.api.PixelfedAPI
import com.h.pixeldroid.objects.*
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Rule
import org.junit.Test
import retrofit2.Call
@ -132,4 +131,54 @@ class APIUnitTest {
f.emojis== emptyList<Emoji>() && f.reblogs_count==0 && f.favourites_count==0&& f.replies_count==0 && f.url=="https://pixelfed.de/p/Miike/140364967936397312")
assert(f.in_reply_to_id==null && f.in_reply_to_account==null && f.reblog==null && f.poll==null && f.card==null && f.language==null && f.text==null && !f.favourited && !f.reblogged && !f.muted && !f.bookmarked && !f.pinned)
}
@Test
fun register_application(){
stubFor(
post(urlEqualTo("/api/v1/apps"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(""" {"id":3197,"name":"Pixeldroid","website":null,"redirect_uri":"urn:ietf:wg:oauth:2.0:oob","client_id":3197,"client_secret":"hhRwLupqUJPghKsZzpZtxNV67g5DBdPYCqW6XE3m","vapid_key":null}"""
)))
val call: Call<Application> = PixelfedAPI.create("http://localhost:8089")
.registerApplication("Pixeldroid", "urn:ietf:wg:oauth:2.0:oob", "read write follow")
val application: Application = call.execute().body()!!
assertEquals("3197", application.client_id)
assertEquals("hhRwLupqUJPghKsZzpZtxNV67g5DBdPYCqW6XE3m", application.client_secret)
assertEquals("Pixeldroid", application.name)
assertEquals(null, application.website)
assertEquals(null, application.vapid_key)
}
@Test
fun obtainToken(){
stubFor(
post(urlEqualTo("/oauth/token"))
.willReturn(
aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""{
"access_token": "ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0",
"token_type": "Bearer",
"scope": "read write follow push",
"created_at": 1573979017
}"""
)))
val OAUTH_SCHEME = "oauth2redirect"
val SCOPE = "read write follow"
val PACKAGE_ID = "com.h.pixeldroid"
val call: Call<Token> = PixelfedAPI.create("http://localhost:8089")
.obtainToken("123", "ssqdfqsdfqds", "$OAUTH_SCHEME://$PACKAGE_ID", SCOPE, "abc",
"authorization_code")
val token: Token = call.execute().body()!!
assertEquals("ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0", token.access_token)
assertEquals("Bearer", token.token_type)
assertEquals("read write follow push", token.scope)
assertEquals(1573979017, token.created_at)
assertEquals(Token("ZA-Yj3aBD8U8Cm7lKUp-lm9O9BmDgdhHzDeqsY8tlL0", "Bearer", "read write follow push",1573979017), token)
}
}

View File

@ -1,17 +0,0 @@
package com.h.pixeldroid
import org.junit.Test
import org.junit.Assert.*
/**
* Example local unit test, which will execute on the development machine (host).
*
* See [testing documentation](http://d.android.com/tools/testing).
*/
class ExampleUnitTest {
@Test
fun addition_isCorrect() {
assertEquals(4, 2 + 2)
}
}