From 9c44029b5ab1b507d4f54b081dca7b3a9341deb3 Mon Sep 17 00:00:00 2001 From: zexigong <108034351+zexigong@users.noreply.github.com> Date: Mon, 29 May 2023 00:02:09 -0700 Subject: [PATCH 1/6] Oauth 2 --- src/Oauth.js | 284 ++++++++++++++++++ src/components/authentication/Login.vue | 44 +++ .../authentication/LoginSuccess.vue | 11 + src/components/landing_page/LandingPage.vue | 5 +- src/router/index.js | 6 + 5 files changed, 349 insertions(+), 1 deletion(-) create mode 100644 src/Oauth.js create mode 100644 src/components/authentication/Login.vue create mode 100644 src/components/authentication/LoginSuccess.vue diff --git a/src/Oauth.js b/src/Oauth.js new file mode 100644 index 00000000..b6c541ac --- /dev/null +++ b/src/Oauth.js @@ -0,0 +1,284 @@ +// PKCE code Copyright (c) 2019 Aaron Parecki +// MIT Public License +// https://github.com/aaronpk/pkce-vanilla-js + +/*global $ Handlebars */ + +// Particular to this app. Change if you fork! +const getRandomValues = require('get-random-values') +const sha256 = require('crypto-js/sha256'); +const crypto = require('crypto'); + +module.exports = { + ajax, + startLogin, +} + +const server = 'https://apiclient.wiki/' +const clientID = "6ac53a07b581e30e47664cd9e8f3d0e4" + +// OAuth routes + +const authroot = `https://meta.wikimedia.org/w/rest.php` +const authorize = `${authroot}/oauth2/authorize` +const token = `${authroot}/oauth2/access_token` +const profileurl = `${authroot}/oauth2/resource/profile` + +console.log(10086); + +// API root + +const root = 'https://api.wikimedia.org/core/v1/wikipedia/en/' +const feeds = 'https://api.wikimedia.org/feed/v1/wikipedia/en/' + +function ajax(args) { + ensureToken(function(token) { + if (token) { + args.headers = (args.headers) ? args.headers : {} + args.headers['Authorization'] = `Bearer ${token}` + } + if (!args.error) { + args.error = function(jqXHR, textStatus, errorThrown) { + showError(`Error calling ${args.url}: ${errorThrown}`) + } + } + $.ajax(args) + }) +} + +const ensureToken = function(callback) { + let results = getLoginResults() + // Are we past the expiry date? + if (Date.now() > results.access_token_expired_ms) { + let pkce = loadPKCE() + let data = { + grant_type: "refresh_token", + refresh_token: results.refresh_token, + redirect_uri: `${server}callback`, + client_id: clientID, + code_verifier: pkce.codeVerifier + } + // We don't want to use access_token for this + $.post({ + url: token, + dataType: "json", + data: data, + success: function(newResults) { + saveLoginResults(newResults) + callback(newResults.access_token) + }, + error: function(jqXHR, textStatus, errorThrown) { + showError(`Error getting OAuth 2.0 refresh token: ${errorThrown}`) + } + }) + } else { + callback(results.access_token) + } +} + +var timer = null + +// from https://stackoverflow.com/questions/1267283/how-can-i-pad-a-value-with-leading-zeros + +const lp = function(number, width) { + width -= number.toString().length; + if ( width > 0 ) + { + return new Array( width + (/\./.test( number ) ? 2 : 1) ).join( '0' ) + number; + } + return number + ""; // always return a string +} + +function startLogin() { + let pkce = makePKCE() + savePKCE(pkce) + let params = { + client_id: clientID, + response_type: "code", + redirect_uri: `${server}callback`, + state: getPath(), + code_challenge: pkce.codeChallenge, + code_challenge_method: "S256" + } + let str = $.param(params) + let url = `${authorize}?${str}` + window.location = url + return false +} + +const endLogin = function() { + let query = (new URL(document.location)).searchParams + let state = query.get('state') + let error = query.get('error') + if (error) { + let errorDescription = query.get('error_description') + let message = query.get('message') + showError((message) ? message : ((errorDescription) ? errorDescription : error)) + if (state) { + routeTo(state) + } else { + routeTo('/') + } + return + } + let code = query.get('code') + let pkce = loadPKCE() + let data = { + grant_type: "authorization_code", + code: code, + redirect_uri: `${server}callback`, + client_id: clientID, + code_verifier: pkce.codeVerifier + } + // We don't want to use a token for this + $.post({ + url: token, + dataType: "json", + data: data, + success: function(results) { + saveLoginResults(results) + getProfile(function(profile) { + if (profile) { + saveProfile(profile) + } + resetNavbar() + routeTo(state) + }) + }, + error: function(xhr, status, text) { + showError(`Error finishing authorization: ${text}`) + } + }) +} + +const saveLoginResults = function(results) { + // TODO: save other important data + localStorage.setItem('access_token', results.access_token) + localStorage.setItem('refresh_token', results.refresh_token) + localStorage.setItem('access_token_expired_ms', (Date.now() + results.expires_in * 1000.0).toString()) +} + +const clearLoginResults = function() { + // TODO: save other important data + localStorage.removeItem('access_token') + localStorage.removeItem('refresh_token') + localStorage.removeItem('access_token_expired_ms') +} + +const getLoginResults = function() { + return { + access_token: localStorage.getItem('access_token'), + refresh_token: localStorage.getItem('refresh_token'), + access_token_expired_ms: parseFloat(localStorage.getItem('access_token_expired_ms')) + } +} + +const isLoggedIn = function() { + return getAccessToken() +} + +const getAccessToken = function() { + return localStorage.getItem('access_token') +} + +const getProfile = function(callback) { + ajax({ + url: profileurl, + success: function(profile) { + // workaround: this endpoint sometimes returns text/html instead of application/json + if (typeof(profile) == "string") { + profile = JSON.parse(profile) + } + callback(profile) + }, + error: function() { + // Just continue + callback(null) + } + }) +} + +const loadProfile = function() { + let profile = { + username: localStorage.getItem('username') + } + return profile +} + +const saveProfile = function(profile) { + localStorage.setItem('username', profile.username) +} + +const clearProfile = function() { + localStorage.removeItem('username') +} + +const getQuery = function() { + return (new URL(document.location)).searchParams +} + +const makePKCE = function() { + let pkce = { + state: generateRandomString(), + codeVerifier: generateRandomString() + } + pkce.codeChallenge = pkceChallengeFromVerifier(pkce.codeVerifier) + return pkce +} + +const savePKCE = function(pkce) { + if (typeof localStorage === "undefined" || localStorage === null) { + var LocalStorage = require('node-localstorage').LocalStorage; + localStorage = new LocalStorage('./scratch'); + } + localStorage.setItem("pkce_state", pkce.state) + localStorage.setItem("pkce_code_verifier", pkce.codeVerifier) +} + +const loadPKCE = function() { + let pkce = { + state: localStorage.getItem("pkce_state"), + codeVerifier: localStorage.getItem("pkce_code_verifier") + } + pkce.codeChallenge = pkceChallengeFromVerifier(pkce.codeVerifier) + return pkce +} + +const clearPKCE = function() { + localStorage.removeItem("pkce_state") + localStorage.removeItem("pkce_code_verifier") +} + +/* global Uint32Array */ +// changed to Unit8Array due to some errors. Do we need to fix it? + +const generateRandomString = function() { + var array = new Uint8Array(28) + getRandomValues(array) +// console.log(getRandomValues(array)) + return Array.from(array, dec => ('0' + dec.toString(16)).substr(-2)).join('') +} + +// Base64-urlencodes the input string +function base64urlencode(str) { + return Buffer.from(str).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') +} + +/* global sha256 */ + +function pkceChallengeFromVerifier(v) { + let hash = crypto.createHash('sha256') + // let hash = sha256.create() + hash.update(v) + // let hashed = hash.array() + let stringed = String.fromCharCode.apply(null, hash) + let challenge = base64urlencode(stringed) + return challenge +} + +const logout = function () { + clearLoginResults() + clearProfile() + clearPKCE() +} + diff --git a/src/components/authentication/Login.vue b/src/components/authentication/Login.vue new file mode 100644 index 00000000..5c446f18 --- /dev/null +++ b/src/components/authentication/Login.vue @@ -0,0 +1,44 @@ + + + diff --git a/src/components/authentication/LoginSuccess.vue b/src/components/authentication/LoginSuccess.vue new file mode 100644 index 00000000..40210797 --- /dev/null +++ b/src/components/authentication/LoginSuccess.vue @@ -0,0 +1,11 @@ + + + + + \ No newline at end of file diff --git a/src/components/landing_page/LandingPage.vue b/src/components/landing_page/LandingPage.vue index 629f6d72..789dd9c1 100644 --- a/src/components/landing_page/LandingPage.vue +++ b/src/components/landing_page/LandingPage.vue @@ -1,6 +1,7 @@ diff --git a/src/components/authentication/LoginSuccess.vue b/src/components/authentication/LoginSuccess.vue index 40210797..6ffd3102 100644 --- a/src/components/authentication/LoginSuccess.vue +++ b/src/components/authentication/LoginSuccess.vue @@ -1,11 +1,53 @@ - - \ No newline at end of file + }, + mounted: async function() { + await this.getAccessToken(); + await this.getUserProfile(); + } +}; + From 2c6a5263e72078bac444ac5be62a4ddd019fb4b2 Mon Sep 17 00:00:00 2001 From: zexigong <108034351+zexigong@users.noreply.github.com> Date: Wed, 31 May 2023 19:57:51 -0700 Subject: [PATCH 3/6] Show username --- src/components/authentication/Login.vue | 4 +++- src/components/authentication/UserProfile.vue | 21 +++++++++++++++++++ src/components/landing_page/LandingPage.vue | 6 ++++-- src/components/menu/MainToolbar.vue | 5 ++++- 4 files changed, 32 insertions(+), 4 deletions(-) create mode 100644 src/components/authentication/UserProfile.vue diff --git a/src/components/authentication/Login.vue b/src/components/authentication/Login.vue index 3290cc8a..e903c3db 100644 --- a/src/components/authentication/Login.vue +++ b/src/components/authentication/Login.vue @@ -1,5 +1,8 @@ \ No newline at end of file diff --git a/src/components/landing_page/LandingPage.vue b/src/components/landing_page/LandingPage.vue index 789dd9c1..e2b44ceb 100644 --- a/src/components/landing_page/LandingPage.vue +++ b/src/components/landing_page/LandingPage.vue @@ -2,6 +2,7 @@
+
@@ -203,6 +204,7 @@ import TopicSearchBox from "@/components/TopicSearchBox"; import MainToolBar from "@/components/menu/MainToolbar"; import LoginButton from "@/components/authentication/Login"; + export default { name: "LandingPage", props: {}, @@ -229,8 +231,8 @@ export default { TopicSearchBox, // WikimapsWarperLayer, MainToolBar, - LoginButton - }, + LoginButton, +}, mounted: function() { this.$store.commit("resetState"); // this.$store.commit('setSelectedBasemap', this.mapOfTheDay); diff --git a/src/components/menu/MainToolbar.vue b/src/components/menu/MainToolbar.vue index 192d713b..552b6799 100644 --- a/src/components/menu/MainToolbar.vue +++ b/src/components/menu/MainToolbar.vue @@ -12,6 +12,7 @@ {{ landingPageName }} +
@@ -19,6 +20,7 @@ \ No newline at end of file diff --git a/src/components/landing_page/LandingPage.vue b/src/components/landing_page/LandingPage.vue index e2b44ceb..28a6d749 100644 --- a/src/components/landing_page/LandingPage.vue +++ b/src/components/landing_page/LandingPage.vue @@ -1,7 +1,6 @@ @@ -20,6 +21,7 @@ + + + + diff --git a/src/components/authentication/UserProfile.vue b/src/components/authentication/UserProfile.vue index 04c0b7c8..999cec1e 100644 --- a/src/components/authentication/UserProfile.vue +++ b/src/components/authentication/UserProfile.vue @@ -2,7 +2,7 @@