Skip to content

Commit

Permalink
Merge pull request #495 from pennlabs/persist-2fa
Browse files Browse the repository at this point in the history
Upsert Path Registration in PCP upon fetching courses from Path
  • Loading branch information
anli5005 authored Jan 21, 2024
2 parents 45a9282 + f469ced commit d19c818
Show file tree
Hide file tree
Showing 7 changed files with 138 additions and 30 deletions.
32 changes: 14 additions & 18 deletions PennMobile/Auth/PennLoginController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -76,28 +76,24 @@ class PennLoginController: UIViewController, WKUIDelegate, WKNavigationDelegate
// Webview has redirected to desired site.
self.handleSuccessfulNavigation(webView, decisionHandler: decisionHandler)
} else {
if url.absoluteString.contains("password") {
webView.evaluateJavaScript("document.getElementById('pennname').value;") { (result, _) in
if let pennkey = result as? String {
webView.evaluateJavaScript("document.getElementById('password').value;") { (result, _) in
if let password = result as? String {
if !pennkey.isEmpty && !password.isEmpty {
self.pennkey = pennkey
self.password = password
if pennkey == "root" && password == "root" {
self.handleDefaultLogin(decisionHandler: decisionHandler)
return
}
webView.evaluateJavaScript("document.querySelector('input[name=j_username]').value;") { (result, _) in
if let pennkey = result as? String {
webView.evaluateJavaScript("document.querySelector('input[name=j_password]').value;") { (result, _) in
if let password = result as? String {
if !pennkey.isEmpty && !password.isEmpty {
self.pennkey = pennkey
self.password = password
if pennkey == "root" && password == "root" {
self.handleDefaultLogin(decisionHandler: decisionHandler)
return
}
}
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
} else {
decisionHandler(.allow)
}
}
}
Expand All @@ -123,7 +119,7 @@ class PennLoginController: UIViewController, WKUIDelegate, WKNavigationDelegate
return
}

if url.absoluteString.contains("twostep") {
if url.absoluteString.contains("prompt") {
guard let pennkey = pennkey, let password = password else { return }
if password != KeychainAccessible.instance.getPassword() {
UserDBManager.shared.updateAnonymizationKeys()
Expand All @@ -138,7 +134,7 @@ class PennLoginController: UIViewController, WKUIDelegate, WKNavigationDelegate

func autofillCredentials() {
guard let pennkey = pennkey else { return }
webView.evaluateJavaScript("document.getElementById('pennname').value = '\(pennkey)'") { (_, _) in
webView.evaluateJavaScript("document.getElementById('username').value = '\(pennkey)'") { (_, _) in
}
guard let password = password else { return }
webView.evaluateJavaScript("document.getElementById('password').value = '\(password)'") { (_, _) in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
//
import Foundation
import SwiftyJSON
import PennMobileShared

struct Response: Decodable {
let message: String
Expand All @@ -25,6 +26,7 @@ class CourseAlertNetworkManager: NSObject, Requestable {
let settingsURL = "https://penncoursealert.com/accounts/me/"
let coursesURL = "https://penncoursealert.com/api/base/"
let registrationsURL = "https://penncoursealert.com/api/alert/registrations/"
let pathRegistrationURL = "https://penncourseplan.com/api/plan/schedules/path/"

func getSearchedCourses(searchText: String, _ callback: @escaping (_ results: [CourseSection]?) -> Void) {

Expand Down Expand Up @@ -158,6 +160,26 @@ class CourseAlertNetworkManager: NSObject, Requestable {
}
}

func updatePathRegistration(srcdb: String, crns: [String]) async throws {
let params: [String: Any] = ["semester": srcdb, "sections": crns.map { ["id": $0] }]

return try await withCheckedThrowingContinuation { continuation in
makeAuthenticatedRequest(url: pathRegistrationURL, requestType: RequestType.PUT, params: params) { (data, response, error) in
if let error {
continuation.resume(throwing: error)
return
}

guard let response = response as? HTTPURLResponse, response.statusCode == 200 else {
continuation.resume(throwing: NetworkingError.serverError)
return
}

continuation.resume(returning: ())
}
}
}

}

// MARK: - General Networking Functions
Expand Down Expand Up @@ -233,10 +255,10 @@ extension CourseAlertNetworkManager {
if let CSRFDict = (UserDefaults.standard.dictionary(forKey: "cookies"))?["csrftokenplatform.pennlabs.org"] as? [String: Any] {
if let csrfToken = CSRFDict["Value"] as? String {
callback(csrfToken)
} else {
callback(nil)
return
}
}

callback(nil)
}

Expand Down
2 changes: 1 addition & 1 deletion PennMobile/Courses/Views/CoursesView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ struct CoursesView: View {
.foregroundColor(.red)
.padding()
.onAppear {
if case PathAtPennError.noTokenFound = error {
if case PathAtPennError.noTokenFound(_) = error {
isPresentingLoginSheet = true
}
}
Expand Down
2 changes: 1 addition & 1 deletion PennMobile/Dining/Controllers/DiningLoginController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ class DiningLoginController: UIViewController, WKUIDelegate, WKNavigationDelegat

if url.absoluteString.contains("https://weblogin.pennkey.upenn.edu/") {
guard let pennkey = KeychainAccessible.instance.getPennKey(), let password = KeychainAccessible.instance.getPassword() else { return }
webView.evaluateJavaScript("document.getElementById('pennname').value = '\(pennkey)'") { (_, _) in
webView.evaluateJavaScript("document.getElementById('username').value = '\(pennkey)'") { (_, _) in
webView.evaluateJavaScript("document.getElementById('password').value = '\(password)'") { (_, _) in
}
}
Expand Down
2 changes: 1 addition & 1 deletion PennMobile/Login/LabsLoginController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ class LabsLoginController: PennLoginController, IndicatorEnabled, Requestable, S
}

override var shouldLoadCookies: Bool {
return false
return true
}

private let codeVerifier = String.randomString(length: 64)
Expand Down
97 changes: 90 additions & 7 deletions PennMobile/Login/PathAtPennNetworkManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,23 @@
import Foundation
import SwiftSoup
import PennMobileShared
import OSLog

enum PathAtPennError: Error {
/// The user's pennkey/password are not stored on the keychain
case pennkeyCredentialsNotStored

/// Unable to construct getToken request body
case invalidRequestBody

/// The data was a malformed string.
case corruptString

/// An execution identifier was not found in the login response.
case noExecutionFound

/// A token was not found in the Path@Penn authorization response.
case noTokenFound
case noTokenFound(String)

/// The returned ``URLResponse`` was not an ``HTTPURLResponse``.
case notHttpResponse
Expand All @@ -29,22 +39,95 @@ enum PathAtPennError: Error {

class PathAtPennNetworkManager {
static let instance = PathAtPennNetworkManager()
let logger = Logger(category: "PathAtPennNetworkManager")
}

// MARK: - Path@Penn Authentication

extension PathAtPennNetworkManager {
static private let oauthURL = URL(string: "https://idp.pennkey.upenn.edu/idp/profile/oidc/authorize?response_type=code&scope=openid+email+profile&client_id=courses.upenn.edu%2Fsam_rMReby8Vl3M1BiyVWMAT&redirect_uri=https%3A%2F%2Fcourses.upenn.edu%2Fsam%2Fcodetotoken")!

/// Fetches and returns a Path@Penn auth token.
func getToken() async throws -> String {
private func getTokenWithoutReauthenticating() async throws -> String {
let (data, _) = try await URLSession.shared.data(from: PathAtPennNetworkManager.oauthURL)
let str = try String(data: data, encoding: .utf8).unwrap(orThrow: PathAtPennError.corruptString)
let matches = str.getMatches(for: "value: \"(.*)\"")
let token = try matches.first.unwrap(orThrow: PathAtPennError.noTokenFound)

let token = try matches.first.unwrap(orThrow: PathAtPennError.noTokenFound(str))
return token
}

/// Fetches and returns a Path@Penn auth token.
func getToken() async throws -> String {
// First, attempt to acquire a token without reauthenticating
do {
return try await getTokenWithoutReauthenticating()
} catch PathAtPennError.noTokenFound(let body) {
logger.warning("Reauthenticating user for Path@Penn")

// Attempt to reauthenticate the user
guard let pennkey = KeychainAccessible.instance.getPennKey(),
let password = KeychainAccessible.instance.getPassword() else {
throw PathAtPennError.pennkeyCredentialsNotStored
}

var urlComponents = URLComponents()
urlComponents.queryItems = [
URLQueryItem(name: "j_username", value: pennkey),
URLQueryItem(name: "j_password", value: password),
URLQueryItem(name: "_eventId_proceed", value: "")
]

guard let requestBody = urlComponents.percentEncodedQuery?.data(using: .utf8) else {
throw PathAtPennError.invalidRequestBody
}

let authorizeDOM = try SwiftSoup.parse(body)
guard let form = try authorizeDOM.getElementById("loginform") else {
throw PathAtPennError.noExecutionFound
}

let loginStr = try form.attr("action")
guard let loginURL = URL(string: loginStr, relativeTo: URL(string: "https://weblogin.pennkey.upenn.edu")!) else {
throw PathAtPennError.noExecutionFound
}

var request = URLRequest(url: loginURL)
request.httpMethod = "POST"
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = requestBody

let (data, response) = try await URLSession.shared.data(for: request)
let twoFactorStr = try String(data: data, encoding: .utf8).unwrap(orThrow: PathAtPennError.corruptString)
let twoFactorDOM = try SwiftSoup.parse(twoFactorStr)
let twoFactorURL = try response.url.unwrap(orThrow: PathAtPennError.noExecutionFound)

urlComponents = URLComponents()
let formFields = ["tx", "parent", "_xsrf"]

urlComponents.queryItems = try formFields.map { name in
guard let element = try twoFactorDOM.getElementsByAttributeValue("name", name).first() else {
throw PathAtPennError.noExecutionFound
}

return try URLQueryItem(name: name, value: element.val())
}

guard let twoFactorRequestBody = urlComponents.percentEncodedQuery?.data(using: .utf8) else {
throw PathAtPennError.invalidRequestBody
}

request = URLRequest(url: twoFactorURL)
request.httpMethod = "POST"
request.addValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
request.httpBody = twoFactorRequestBody

let (postData, postResponse) = try await URLSession.shared.data(for: request)

return try await getTokenWithoutReauthenticating()
} catch {
throw error
}
}
}

// MARK: - Student Data
Expand Down Expand Up @@ -146,11 +229,11 @@ extension PathAtPennNetworkManager {
let (srcdb, descriptors) = $0

let crns = descriptors.compactMap {
$0.split(separator: "|").first
$0.split(separator: "|").first.map { String($0) }
}

return try await crns.asyncMap { crn in
try await self.fetchCourse(srcdb: srcdb, crn: String(crn))
try await self.fetchCourse(srcdb: srcdb, crn: crn)
}.compactMap { $0 }
}.flatMap { $0 }
}
Expand Down
7 changes: 7 additions & 0 deletions PennMobileShared/General/Extensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
//

import UIKit
import OSLog

extension UIApplication {
public static var isRunningFastlaneTest: Bool {
Expand Down Expand Up @@ -668,3 +669,9 @@ public extension JSONDecoder {
self.dateDecodingStrategy = dateDecodingStrategy
}
}

public extension Logger {
init(category: String) {
self.init(subsystem: Bundle.main.bundleIdentifier ?? "Penn Mobile", category: category)
}
}

0 comments on commit d19c818

Please sign in to comment.